Compare commits

...

129 Commits

Author SHA1 Message Date
Katherine
7420866dfc
MultiMC metadata: Support updating NeoForge version (#75) 2024-04-21 12:10:24 +01:00
comp500
1ebb28c3cc Make update progress reporting more descriptive and useful
Removed a deleteIfExists call - this should be handled by DownloadTask instead
2023-10-24 00:43:49 +01:00
comp500
c9543f74ee Fix invalidation issues when changing --side without updating pack 2023-10-23 23:36:17 +01:00
Falxie_
b2421cfea7
Add open missing mods button (#66)
* add open missing mods button

* hide missing mods button if none are found

* add logging to openUrl
2023-08-06 17:54:30 +01:00
comp500
6f05ac6bf0 Fix CLI updates being cancelled when there is launcher metadata to update 2023-06-23 17:58:16 +01:00
comp500
7b6daaf7e5 Allow multiple files from the same CurseForge project 2023-05-30 02:09:45 +01:00
comp500
758385c225 Fix onlyOtherSide state not updating properly when side changes (fixes #60) 2023-05-20 05:09:25 +01:00
comp500
304fb802ed Write packwiz.json with UTF8 instead of system charset 2023-04-15 02:03:05 +01:00
comp500
cc063773d8 Only run build on PR (no publishing) 2023-04-12 23:52:54 +01:00
comp500
1deed7dd0d Fix and simplify side matching code (fixes #54) 2023-01-17 05:21:20 +00:00
comp500
ad951b9b44 Download mods for both sides with --side both (fixes #14) 2023-01-17 02:33:07 +00:00
Eric Richter
4e415c1e1a
Add option to automatically close after a user-supplied amount of time (#42)
* Add option to automatically close after a user-specific amount of time

Add the -t/--timeout command line option to specific a number of seconds to
wait before automatically closing if there is no update or user-interactivity
required.

* Also use timeout setting when prompting about optional mods after/during an update

Additionally, clears the TODO comment.

* Change default timeout to 10 seconds
2023-01-17 02:30:05 +00:00
comp500
84bbbe0770 Tweak timeout strategy (see #52) 2023-01-15 02:23:18 +00:00
comp500
fa9fe18215 Fix manual link resolution for files not visible in CF API (fixes #48) 2023-01-02 21:15:48 +00:00
comp500
01dcc09a78 Add main method so link.infra.packwiz.installer.Main can be launched directly
Useful for scripts or tools that already handle automatic updates of
packwiz-installer, such as docker-minecraft-server :)
Adds a message to suggest using the bootstrapper for automatic updates
2022-09-01 02:44:12 +01:00
comp500
a8f8444d45 Fix handling of old packwiz.json files with negative murmur2 values 2022-07-18 16:40:39 +01:00
comp500
d98baaf832 Change manifest file back to packwiz.json 2022-07-17 14:26:46 +01:00
comp500
783e35cf73 Fix OutOfBoundsException when parsing a single letter path component 2022-07-17 01:07:48 +01:00
comp500
ca172bdefc Fix order of modified MultiMC components when updating versions 2022-07-17 01:03:40 +01:00
comp500
b8cb9cc1aa Repack jar with Gradle so MANIFEST.MF is at the start
packwiz-installer-bootstrap relies on reading the manifest; breaking this means the update will run on every launch!
2022-07-17 00:30:22 +01:00
comp500
6f0beac1a1 Dry run fails the build when the release already exists; fix
Reverted to stop the snapshot build failing
2022-07-16 20:40:10 +01:00
comp500
f7257f4266 Fix dist jar path, do dry run when not releasing 2022-07-16 18:47:08 +01:00
comp500
9c475cba85 Fix gh release dependency on copyJar output 2022-07-16 18:15:58 +01:00
comp500
f1ba5e4343 Fix githubRelease as publish task dep 2022-07-16 18:02:29 +01:00
comp500
43873ac7f9 Add githubRelease as publish task dep 2022-07-16 17:58:13 +01:00
comp500
d83c4f1abc Pass tokens as command line arguments 2022-07-16 17:43:54 +01:00
comp500
db304f9d00 Upgradle to 7.5 2022-07-16 17:33:59 +01:00
comp500
6bb360f8e3 Fix environment variables passed to Gradle again 2022-07-16 17:19:24 +01:00
comp500
4115ea2a3a Fix environment variables passed to Gradle 2022-07-16 17:16:16 +01:00
comp500
d4e41ad85e Fix gradlew permissions 2022-07-16 17:11:48 +01:00
comp500
fcf249166c Add GH actions Maven publishing, improve buildscript 2022-07-16 17:10:26 +01:00
comp500
66bc4c3e29 Significant rewrite to use 4koma, OkHttp, PackwizPath; fixing several issues
Github release plugin, Kotlin, Okio and OkHttp updated
Toml4j removed and replaced with 4koma - improves null safety, immutability, TOML compliance
kotlin-result removed (I don't think it was used anyway)
SpaceSafeURI replaced with PackwizPath which handles special characters much better (fixes #5)
Fixed directory traversal issues
Hashing system rewritten for better type safety and cleaner code
Download mode changed to use an enum
Request system completely rewritten; now uses OkHttp for HTTP requests (fixes #36, fixes #5)
Added request interceptor which retries requests (fixes #4)
Removed: support for extracting packs from zip files (and Github zipballs)
Cleaner exceptions; more improvements pending (in particular showing request failure causes)
Improved speed of cancelling in-progress downloads
Better support for installing from local files (no file: URI required; though it is still supported)
Various code cleanup changes
2022-07-10 01:44:35 +01:00
comp500
02b01b90d7 Improvements to MultiMC metadata updating
Reformatted and refactored names to fit Kotlin conventions
Closed manifest file reader properly
Fixed switching between loaders, improved intermediary logic
Improved error messages
2022-06-29 03:26:50 +01:00
comp500
ab3de9a246 Merge remote-tracking branch 'origin/master' 2022-06-28 18:04:40 +01:00
Geferon
53286871e6
Initial MultiMC Forge and MC version support (#31)
* Initial MultiMC Forge and MC version support

* Added support for MultiMC MC and Forge versioning

* Added support for Fabric, added MultiMC confirmation prompt

* Refactored prompt method for MultiMC update

* Initial PR changes

* Refactored most MultiMC code to LauncherUtils
2022-06-28 17:52:26 +01:00
comp500
610aeeb166 Change usage of deprecated toLowerCase method 2022-06-19 19:40:14 +01:00
comp500
5e39907fae Fix build on J9+, exclude kotlin_metadata again (used to be removed with annots, but those are necessary) 2022-06-19 19:39:45 +01:00
comp500
d2556c4b4a Move to R8 shrinker, tweak compile args 2022-06-19 18:58:49 +01:00
comp500
858fd17f3e Fix optional and sided mod filtering properly 2022-05-23 20:32:22 +01:00
comp500
c2ee6fca8b Improve handling of manual downloads; "Continue" now rechecks files 2022-05-23 17:38:01 +01:00
comp500
73d21a475a Fix phantom state of opted-out files made non-optional 2022-05-23 00:55:58 +01:00
comp500
7568770078 Fix optional button waiting when there are no optional mods 2022-05-23 00:38:57 +01:00
comp500
3d1d6db9b4 Update licenses 2022-05-22 21:44:22 +01:00
comp500
c6e304bc7f Add support for mode field, with CurseForge metadata lookup
Now always asks the user before proceeding past the point where optional mods could be selected and configured
When updating files, the hash is checked so an update isn't redownloaded if it already exists
Added DevMain file for running in a dev environment
2022-05-22 21:20:52 +01:00
comp500
92d6f68f1d Always use UTF-8 for reading TOML files (fixes #22) 2022-05-11 17:45:39 +01:00
comp500
07af6046c1 Rework target into interface; add overwrite mode and validity/identity tokens 2022-03-06 21:28:27 +00:00
comp500
89bdfd9c98 WIP task system with lazy evaluation 2022-02-21 22:15:10 +00:00
comp500
f4dd4fa866 Implement new abstraction for file paths
Once integrated with the rest of the installer, this should fix many directory traversal and path encoding issues
2022-02-21 22:15:10 +00:00
comp500
6db8422c87 Add source link, update report issue link 2022-02-21 22:15:10 +00:00
comp500
7d6346c088 Shade and relocate Commons CLI, update to 1.5.0 2022-02-21 22:15:10 +00:00
comp500
aff921f67e Update dependencies, fix build with Java 9+ 2022-02-21 22:15:10 +00:00
comp500
afb574d82d Remove unused client/server fields 2022-02-21 22:15:10 +00:00
comp500
8635906b1c Add maven publishing target 2022-02-21 22:15:10 +00:00
comp500
bf95f03a18 Start internal rewrite of file download system 2022-02-21 22:15:10 +00:00
comp500
bca2d758e1 Fix SpaceSafeURI nullability issues 2022-02-21 22:03:56 +00:00
comp500
46771ce870 Clarify error message for missing index file 2021-07-16 04:07:45 +01:00
comp500
b143f67acd Fix symlink check by catching the correct exception 2021-06-22 13:54:06 +01:00
comp500
03b0f1b09b Fix disable options button always changing to No optional mods... 2021-02-16 16:26:23 +00:00
comp500
6c6a0100fd Bundle licenses, disable relocation for now 2021-02-16 16:19:16 +00:00
comp500
6d47c0d61f Relocate shadow deps 2021-02-07 14:02:10 +00:00
comp500
226e754547 Add SHA1 support (see https://github.com/comp500/packwiz/pull/11) 2021-01-29 06:50:50 +00:00
comp500
2c02703101 Ask the user if they want to continue launching, if update/install fails 2020-12-15 20:24:49 +00:00
comp500
81a60cc759 Remove kotlinx.serialization gradle plugin as I didn't end up using it 2020-12-15 19:43:30 +00:00
comp500
92afa93fd7 Update deps, shrink fatjar (no obf) with proguard 2020-12-15 19:40:18 +00:00
comp500
0858c90079 Rework error handling to be more robust 2020-12-15 17:28:23 +00:00
comp500
1d4c94f5b6 Make the main window unresizable, to work better with tiling window managers 2020-12-13 17:00:35 +00:00
comp500
74ddca5d54 Remove unnecessary Futures from IUserInterface API 2020-12-13 16:57:12 +00:00
comp500
0df48d19a9 Separate IUserInterface logic out into GUIHandler 2020-12-13 16:34:00 +00:00
comp500
f5b22f37a4 Refactor GUI code, remove bad SwingWorker junk 2020-12-13 16:12:44 +00:00
comp500
f52cd19ad4 Show download exceptions properly in CLI 2020-12-11 18:18:10 +00:00
comp500
60887a4312 Whoops 2020-12-07 17:42:52 +00:00
comp500
a368268038 Fix support for symlinked directories 2020-12-07 17:38:21 +00:00
comp500
8beded7b41 Improve UX when there are no optional mods 2020-12-06 19:05:56 +00:00
comp500
91060dcd54 Put an error message there. Later is now! 2020-11-30 00:24:47 +00:00
comp500
e06ee21f3b Add User-Agent to download requests 2020-10-22 20:53:36 +01:00
comp500
b3370739a5 Fix Swing multithreading issue, clean up slightly 2020-09-29 02:14:56 +01:00
comp500
ecc6f0440a Remove IntelliJ metadata from repo 2020-09-29 02:14:05 +01:00
comp500
92b44352b3 Fix RequestHandlerGithub heuristics, so that Github Releases files work properly 2020-06-20 03:15:18 +01:00
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
91 changed files with 4337 additions and 2033 deletions

27
.github/workflows/pr.yml vendored Normal file
View File

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

29
.github/workflows/release.yml vendored Normal file
View File

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

29
.github/workflows/snapshot.yml vendored Normal file
View File

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

127
.gitignore vendored
View File

@ -1,74 +1,95 @@
# Created by https://www.gitignore.io/api/java,gradle,eclipse
# Edit at https://www.gitignore.io/?templates=java,gradle,eclipse
### Eclipse ###
.metadata
bin/
tmp/
*.tmp
*.bak
*.swp
*~.nib
local.properties
.settings/
.loadpath
.recommenders
# Created by https://www.toptal.com/developers/gitignore/api/java,gradle,intellij+all
# Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle,intellij+all
# External tool builders
.externalToolBuilders/
### Intellij+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# Locally stored "Eclipse launch configurations"
*.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/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .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+all Patch ###
# Ignores the whole .idea folder and all .iml files
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
.sts4-cache/
.idea/
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
*.iml
modules.xml
.idea/misc.xml
*.ipr
# Sonarlint plugin
.idea/sonarlint
### Java ###
# Compiled class file
@ -114,4 +135,4 @@ gradle-app.setting
### Gradle Patch ###
**/build/
# End of https://www.gitignore.io/api/java,gradle,eclipse
# End of https://www.toptal.com/developers/gitignore/api/java,gradle,intellij+all

View File

@ -1,9 +0,0 @@
{
"files.exclude": {
"**/.classpath": true,
"**/.project": true,
"**/.settings": true,
"**/.factorypath": true
},
"java.configuration.updateBuildConfiguration": "interactive"
}

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019
Copyright (c) 2021 comp500
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

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,48 +0,0 @@
plugins {
id 'java'
id 'application'
id 'com.github.johnrengelman.shadow' version '5.0.0'
id 'com.palantir.git-version' version '0.11.0'
}
dependencies {
implementation 'commons-cli:commons-cli:1.4'
implementation 'com.moandjiezana.toml:toml4j:0.7.2'
// TODO: Implement tests
//testImplementation 'junit:junit:4.12'
implementation 'com.google.code.gson:gson:2.8.1'
implementation 'com.squareup.okio:okio:2.2.2'
}
repositories {
jcenter()
}
mainClassName = 'link.infra.packwiz.installer.RequiresBootstrap'
version gitVersion()
jar {
manifest {
attributes(
'Main-Class': 'link.infra.packwiz.installer.RequiresBootstrap',
'Implementation-Version': project.version
)
}
}
// Commons CLI and Minimal JSON are already included in packwiz-installer-bootstrap
shadowJar {
dependencies {
exclude(dependency('commons-cli:commons-cli:1.4'))
exclude(dependency('com.eclipsesource.minimal-json:minimal-json:0.9.5'))
}
}
// Used for vscode launch.json
task copyJar(type: Copy) {
from shadowJar
rename "packwiz-installer-(.*)\\.jar", "packwiz-installer.jar"
into "build/libs/"
}
build.dependsOn copyJar

206
build.gradle.kts Normal file
View File

@ -0,0 +1,206 @@
plugins {
java
application
id("com.github.johnrengelman.shadow") version "7.1.2"
id("com.palantir.git-version") version "0.13.0"
id("com.github.breadmoirai.github-release") version "2.4.1"
kotlin("jvm") version "1.7.10"
id("com.github.jk1.dependency-license-report") version "2.0"
`maven-publish`
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
}
repositories {
mavenCentral()
google()
maven {
url = uri("https://jitpack.io")
}
}
val r8 by configurations.creating
val distJarOutput by configurations.creating {
isCanBeResolved = false
isCanBeConsumed = true
attributes {
attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage::class.java, Usage.JAVA_RUNTIME))
attribute(Bundling.BUNDLING_ATTRIBUTE, project.objects.named(Bundling::class.java, Bundling.EMBEDDED))
}
}
dependencies {
implementation("commons-cli:commons-cli:1.5.0")
implementation("com.google.code.gson:gson:2.9.0")
implementation("com.squareup.okio:okio:3.1.0")
implementation(kotlin("stdlib-jdk8"))
implementation("com.squareup.okhttp3:okhttp:4.10.0")
implementation("cc.ekblad:4koma:1.1.0")
r8("com.android.tools:r8:3.3.28")
}
application {
mainClass.set("link.infra.packwiz.installer.RequiresBootstrap")
}
val gitVersion: groovy.lang.Closure<*> by extra
version = gitVersion()
tasks.jar {
manifest {
attributes["Main-Class"] = "link.infra.packwiz.installer.RequiresBootstrap"
attributes["Implementation-Version"] = project.version
}
}
licenseReport {
renderers = arrayOf<com.github.jk1.license.render.ReportRenderer>(
com.github.jk1.license.render.InventoryMarkdownReportRenderer("licenses.md", "packwiz-installer")
)
filters = arrayOf<com.github.jk1.license.filter.DependencyFilter>(com.github.jk1.license.filter.LicenseBundleNormalizer())
}
tasks.shadowJar {
// 4koma uses kotlin-reflect; requires Kotlin metadata
//exclude("**/*.kotlin_metadata")
//exclude("**/*.kotlin_builtins")
exclude("META-INF/maven/**/*")
exclude("META-INF/proguard/**/*")
// Relocate Commons CLI, so that it doesn't clash with old packwiz-installer-bootstrap (that shades it)
relocate("org.apache.commons.cli", "link.infra.packwiz.installer.deps.commons-cli")
// from Commons CLI
exclude("META-INF/LICENSE.txt")
exclude("META-INF/NOTICE.txt")
}
val shrinkJar by tasks.registering(JavaExec::class) {
val rules = file("src/main/proguard.txt")
val r8File = base.libsDirectory.file(provider {
base.archivesName.get() + "-" + project.version + "-all-shrink.jar"
})
dependsOn(configurations.named("runtimeClasspath"))
inputs.files(tasks.shadowJar, rules)
outputs.file(r8File)
classpath(r8)
mainClass.set("com.android.tools.r8.R8")
args = mutableListOf(
"--release",
"--classfile",
"--output", r8File.get().toString(),
"--pg-conf", rules.toString(),
"--lib", System.getProperty("java.home"),
*(if (System.getProperty("java.version").startsWith("1.")) {
// javax.crypto, necessary on <1.9 for compiling Okio
arrayOf("--lib", System.getProperty("java.home") + "/lib/jce.jar")
} else { arrayOf() }),
tasks.shadowJar.get().archiveFile.get().asFile.toString()
)
}
// MANIFEST.MF must be one of the first 2 entries in the zip for JarInputStream to see it
// Gradle's JAR creation handles this whereas R8 doesn't, so the dist JAR is repacked
val distJar by tasks.registering(Jar::class) {
from(shrinkJar.map { zipTree(it.outputs.files.singleFile) })
archiveClassifier.set("all-repacked")
manifest {
from(shrinkJar.map { zipTree(it.outputs.files.singleFile).matching {
include("META-INF/MANIFEST.MF")
}.singleFile })
}
}
artifacts {
add("distJarOutput", distJar) {
classifier = "dist"
}
}
// Used for vscode launch.json
val copyJar by tasks.registering(Copy::class) {
from(distJar)
rename("packwiz-installer-(.*)\\.jar", "packwiz-installer.jar")
into(layout.buildDirectory.dir("dist"))
outputs.file(layout.buildDirectory.dir("dist").map { it.file("packwiz-installer.jar") })
}
tasks.build {
dependsOn(copyJar)
}
githubRelease {
owner("comp500")
repo("packwiz-installer")
tagName("${project.version}")
releaseName("Release ${project.version}")
draft(true)
token(findProperty("github.token") as String?)
releaseAssets(layout.buildDirectory.dir("dist").map { it.file("packwiz-installer.jar") }.get())
}
tasks.githubRelease {
dependsOn(copyJar)
enabled = project.hasProperty("github.token") && project.findProperty("release") == "true"
}
tasks.publish {
dependsOn(tasks.githubRelease)
}
tasks.compileKotlin {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=all", "-Xallow-result-return-type", "-opt-in=kotlin.io.path.ExperimentalPathApi", "-Xlambdas=indy")
}
}
tasks.compileTestKotlin {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = listOf("-Xjvm-default=all", "-Xallow-result-return-type", "-opt-in=kotlin.io.path.ExperimentalPathApi", "-Xlambdas=indy")
}
}
val javaComponent = components["java"] as AdhocComponentWithVariants
javaComponent.addVariantsFromConfiguration(distJarOutput) {
mapToMavenScope("runtime")
mapToOptional()
}
javaComponent.withVariantsFromConfiguration(configurations["shadowRuntimeElements"]) {
skip()
}
if (project.hasProperty("bunnycdn.token")) {
publishing {
publications {
create<MavenPublication>("maven") {
groupId = "link.infra.packwiz"
artifactId = "packwiz-installer"
from(components["java"])
}
}
repositories {
maven {
url = if (project.findProperty("release") == "true") {
uri("https://storage.bunnycdn.com/comp-maven/repository/release")
} else {
uri("https://storage.bunnycdn.com/comp-maven/repository/snapshot")
}
credentials(HttpHeaderCredentials::class) {
name = "AccessKey"
value = findProperty("bunnycdn.token") as String?
}
authentication {
create<HttpHeaderAuthentication>("header")
}
}
}
}
}

Binary file not shown.

View File

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

272
gradlew vendored Normal file → Executable file
View File

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

27
gradlew.bat vendored
View File

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

View File

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

View File

@ -1,24 +1,20 @@
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) {
// Very small CLI implementation, because Commons CLI complains on unexpected
// options
// Also so that Commons CLI can be excluded from the shaded JAR, as it is
// included in the bootstrap
if (Arrays.stream(args).map(str -> {
if (str == null) return "";
if (str.startsWith("--")) {
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,417 +0,0 @@
package link.infra.packwiz.installer;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletionService;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorCompletionService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName;
import com.moandjiezana.toml.Toml;
import link.infra.packwiz.installer.metadata.IndexFile;
import link.infra.packwiz.installer.metadata.ManifestFile;
import link.infra.packwiz.installer.metadata.PackFile;
import link.infra.packwiz.installer.metadata.hash.GeneralHashingSource;
import link.infra.packwiz.installer.metadata.hash.Hash;
import link.infra.packwiz.installer.metadata.hash.HashUtils;
import link.infra.packwiz.installer.request.HandlerManager;
import link.infra.packwiz.installer.ui.IUserInterface;
import link.infra.packwiz.installer.ui.InstallProgress;
import okio.Buffer;
import okio.Okio;
import okio.Source;
public class UpdateManager {
public final Options opts;
public final IUserInterface ui;
public static class Options {
public URI downloadURI = null;
public String manifestFile = "packwiz.json"; // TODO: make configurable
public String packFolder = ".";
public Side side = Side.CLIENT;
public static enum Side {
@SerializedName("client")
CLIENT("client"), @SerializedName("server")
SERVER("server"), @SerializedName("both")
BOTH("both", new Side[] { CLIENT, SERVER });
private final String sideName;
private final Side[] depSides;
Side(String sideName) {
this.sideName = sideName.toLowerCase();
this.depSides = null;
}
Side(String sideName, Side[] depSides) {
this.sideName = sideName.toLowerCase();
this.depSides = depSides;
}
@Override
public String toString() {
return this.sideName;
}
public boolean hasSide(Side tSide) {
if (this.equals(tSide)) {
return true;
}
if (this.depSides != null) {
for (int i = 0; i < this.depSides.length; i++) {
if (this.depSides[i].equals(tSide)) {
return true;
}
}
}
return false;
}
public static Side from(String name) {
String lowerName = name.toLowerCase();
for (Side side : Side.values()) {
if (side.sideName == lowerName) {
return side;
}
}
return null;
}
}
}
public UpdateManager(Options opts, IUserInterface ui) {
this.opts = opts;
this.ui = ui;
this.start();
}
protected void start() {
this.checkOptions();
ui.submitProgress(new InstallProgress("Loading manifest file..."));
Gson gson = new GsonBuilder().registerTypeAdapter(Hash.class, new Hash.TypeHandler()).create();
ManifestFile manifest;
try {
manifest = gson.fromJson(new FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()),
ManifestFile.class);
} catch (FileNotFoundException e) {
manifest = new ManifestFile();
} catch (JsonSyntaxException | JsonIOException e) {
ui.handleExceptionAndExit(e);
return;
}
ui.submitProgress(new InstallProgress("Loading pack file..."));
GeneralHashingSource packFileSource;
try {
Source src = HandlerManager.getFileSource(opts.downloadURI);
packFileSource = HashUtils.getHasher("sha256").getHashingSource(src);
} catch (Exception e) {
// TODO: still launch the game if updating doesn't work?
// TODO: ask user if they want to launch the game, exit(1) if they don't
ui.handleExceptionAndExit(e);
return;
}
PackFile pf;
try {
pf = new Toml().read(Okio.buffer(packFileSource).inputStream()).to(PackFile.class);
} catch (IllegalStateException e) {
ui.handleExceptionAndExit(e);
return;
}
if (manifest.packFileHash != null && packFileSource.hashIsEqual(manifest.packFileHash)) {
System.out.println("Modpack is already up to date!");
// todo: --force?
return;
}
System.out.println("Modpack name: " + pf.name);
try {
processIndex(HandlerManager.getNewLoc(opts.downloadURI, pf.index.file),
HashUtils.getHash(pf.index.hashFormat, pf.index.hash), pf.index.hashFormat, manifest);
} catch (Exception e1) {
ui.handleExceptionAndExit(e1);
}
// TODO: update MMC params, java args etc
manifest.packFileHash = packFileSource.getHash();
try (Writer writer = new FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString())) {
gson.toJson(manifest, writer);
} catch (IOException e) {
// TODO: add message?
ui.handleException(e);
}
}
protected void checkOptions() {
// TODO: implement
}
protected void processIndex(URI indexUri, Hash indexHash, String hashFormat, ManifestFile manifest) {
if (manifest.indexFileHash != null && manifest.indexFileHash.equals(indexHash)) {
System.out.println("Modpack files are already up to date!");
return;
}
manifest.indexFileHash = indexHash;
GeneralHashingSource indexFileSource;
try {
Source src = HandlerManager.getFileSource(indexUri);
indexFileSource = HashUtils.getHasher(hashFormat).getHashingSource(src);
} catch (Exception e) {
// TODO: still launch the game if updating doesn't work?
// TODO: ask user if they want to launch the game, exit(1) if they don't
ui.handleExceptionAndExit(e);
return;
}
IndexFile indexFile;
try {
indexFile = new Toml().read(Okio.buffer(indexFileSource).inputStream()).to(IndexFile.class);
} catch (IllegalStateException e) {
ui.handleExceptionAndExit(e);
return;
}
if (!indexFileSource.hashIsEqual(indexHash)) {
// TODO: throw exception
return;
}
if (manifest.cachedFiles == null) {
manifest.cachedFiles = new HashMap<URI, ManifestFile.File>();
}
ui.submitProgress(new InstallProgress("Checking local files..."));
List<URI> invalidatedUris = new ArrayList<>();
Iterator<Map.Entry<URI, ManifestFile.File>> it = manifest.cachedFiles.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<URI, ManifestFile.File> entry = it.next();
if (entry.getValue().cachedLocation != null) {
Path filePath = Paths.get(opts.packFolder, entry.getValue().cachedLocation);
URI fileUri = entry.getKey();
if (!indexFile.files.stream().anyMatch(f -> f.file.equals(fileUri))) {
// File has been removed from the index
try {
Files.deleteIfExists(filePath);
} catch (IOException e) {
// TODO: should this be shown to the user in some way?
e.printStackTrace();
}
it.remove();
} else if (!Files.exists(filePath)) {
System.out.println("File " + fileUri.toString() + " invalidated, marked for redownloading");
invalidatedUris.add(fileUri);
}
}
}
ui.submitProgress(new InstallProgress("Comparing new files..."));
// TODO: progress bar
ConcurrentLinkedQueue<Exception> exceptionQueue = new ConcurrentLinkedQueue<Exception>();
List<IndexFile.File> newFiles = indexFile.files.stream().map(f -> {
if (f.hashFormat == null || f.hashFormat.length() == 0) {
f.hashFormat = indexFile.hashFormat;
}
return f;
}).filter(f -> {
if (invalidatedUris.contains(f.file)) {
return true;
}
ManifestFile.File cachedFile = manifest.cachedFiles.get(f.file);
Hash newHash;
try {
newHash = HashUtils.getHash(f.hashFormat, f.hash);
} catch (Exception e) {
exceptionQueue.add(e);
return false;
}
return cachedFile == null || !newHash.equals(cachedFile.hash);
}).parallel().map(f -> {
try {
f.downloadMeta(indexFile, indexUri);
} catch (Exception e) {
exceptionQueue.add(e);
}
return f;
}).collect(Collectors.toList());
for (Exception e : exceptionQueue) {
// TODO: collect all exceptions, present in one dialog
ui.handleException(e);
}
// TODO: present options
// TODO: all options should be presented, not just new files!!!!!!!
// and options should be readded to newFiles after option -> true
newFiles.stream().filter(f -> f.linkedFile != null).filter(f -> f.linkedFile.option != null).map(f -> {
return "option: " + (f.linkedFile.option.description == null ? "null" : f.linkedFile.option.description);
}).forEachOrdered(desc -> {
System.out.println(desc);
});
// TODO: different thread pool type?
ExecutorService threadPool = Executors.newFixedThreadPool(10);
CompletionService<DownloadCompletion> completionService = new ExecutorCompletionService<DownloadCompletion>(
threadPool);
for (IndexFile.File f : newFiles) {
ManifestFile.File cachedFile = manifest.cachedFiles.get(f.file);
completionService.submit(new Callable<DownloadCompletion>() {
public DownloadCompletion call() {
DownloadCompletion dc = new DownloadCompletion();
dc.file = f;
if (cachedFile != null && cachedFile.linkedFileHash != null && f.linkedFile != null) {
try {
if (cachedFile.linkedFileHash.equals(f.linkedFile.getHash())) {
// Do nothing, the file didn't change
// TODO: but if the hash of the metafile changed, what did change?????
// should this be checked somehow??
return dc;
}
} catch (Exception e) {}
}
Path destPath = Paths.get(opts.packFolder, f.getDestURI().toString());
// Don't update files marked with preserve if they already exist on disk
if (f.preserve) {
if (Files.exists(destPath)) {
return dc;
}
}
try {
Hash hash;
String fileHashFormat;
if (f.linkedFile != null) {
hash = f.linkedFile.getHash();
fileHashFormat = f.linkedFile.download.hashFormat;
} else {
hash = f.getHash();
fileHashFormat = f.hashFormat;
}
Source src = f.getSource(indexUri);
GeneralHashingSource fileSource = HashUtils.getHasher(fileHashFormat).getHashingSource(src);
Buffer data = new Buffer();
Okio.buffer(fileSource).readAll(data);
if (fileSource.hashIsEqual(hash)) {
Files.createDirectories(destPath.getParent());
Files.copy(data.inputStream(), destPath, StandardCopyOption.REPLACE_EXISTING);
} else {
System.out.println("Invalid hash for " + f.getDestURI().toString());
System.out.println("Calculated: " + fileSource.getHash());
System.out.println("Expected: " + hash);
dc.err = new Exception("Hash invalid!");
}
if (cachedFile != null && !destPath.equals(Paths.get(opts.packFolder, cachedFile.cachedLocation))) {
// Delete old file if location changes
Files.delete(Paths.get(opts.packFolder, cachedFile.cachedLocation));
}
return dc;
} catch (Exception e) {
dc.err = e;
return dc;
}
}
});
}
for (int i = 0; i < newFiles.size(); i++) {
DownloadCompletion ret;
try {
ret = completionService.take().get();
} catch (InterruptedException | ExecutionException e) {
// TODO: collect all exceptions, present in one dialog
ui.handleException(e);
ret = null;
}
// Update manifest
if (ret != null && ret.err == null && ret.file != null) {
ManifestFile.File newCachedFile = new ManifestFile.File();
try {
newCachedFile.hash = ret.file.getHash();
if (newCachedFile.hash == null) {
throw new Exception("Invalid hash!");
}
} catch (Exception e) {
ret.err = e;
}
if (ret.file.metafile && ret.file.linkedFile != null) {
newCachedFile.isOptional = ret.file.linkedFile.isOptional();
if (newCachedFile.isOptional) {
newCachedFile.optionValue = ret.file.optionValue;
}
try {
newCachedFile.linkedFileHash = ret.file.linkedFile.getHash();
} catch (Exception e) {
ret.err = e;
}
}
newCachedFile.cachedLocation = ret.file.getDestURI().toString();
manifest.cachedFiles.put(ret.file.file, newCachedFile);
}
// TODO: show errors properly?
String progress;
if (ret != null) {
if (ret.err != null) {
if (ret.file != null) {
progress = "Failed to download " + ret.file.getName() + ": " + ret.err.getMessage();
} else {
progress = "Failed to download: " + ret.err.getMessage();
}
ret.err.printStackTrace();
} else if (ret.file != null) {
progress = "Downloaded " + ret.file.getName();
} else {
progress = "Failed to download, unknown reason";
}
} else {
progress = "Failed to download, unknown reason";
}
ui.submitProgress(new InstallProgress(progress, i + 1, newFiles.size()));
}
// option = false file hashes should be stored to disk, but not downloaded
// TODO: don't include optional files in progress????
}
private class DownloadCompletion {
Exception err;
IndexFile.File file;
}
}

View File

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

View File

@ -1,21 +0,0 @@
package link.infra.packwiz.installer.metadata;
import java.net.URI;
import java.util.Map;
import link.infra.packwiz.installer.metadata.hash.Hash;
public class ManifestFile {
public Hash packFileHash = null;
public Hash indexFileHash = null;
public Map<URI, File> cachedFiles;
public static class File {
public Hash hash = null;
public boolean isOptional = false;
public boolean optionValue = true;
public Hash linkedFileHash = null;
public String cachedLocation = null;
}
}

View File

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

View File

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

View File

@ -1,31 +0,0 @@
package link.infra.packwiz.installer.metadata;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.URISyntaxException;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
/**
* This class encodes spaces before parsing the URI, so the URI can actually be
* parsed.
*/
class SpaceSafeURIParser implements JsonDeserializer<URI> {
@Override
public URI deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
String uriString = json.getAsString().replace(" ", "%20");
try {
return new URI(uriString);
} catch (URISyntaxException e) {
throw new JsonParseException("Failed to parse URI", e);
}
}
// TODO: replace this with a better solution?
}

View File

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

View File

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

View File

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

View File

@ -1,86 +0,0 @@
package link.infra.packwiz.installer.metadata.hash;
import okio.HashingSource;
import okio.Source;
public class HashingSourceHasher implements IHasher {
String type;
public HashingSourceHasher(String type) {
this.type = type;
}
// i love naming things
private class HashingSourceGeneralHashingSource extends GeneralHashingSource {
HashingSource delegateHashing;
HashingSourceHash value;
public HashingSourceGeneralHashingSource(HashingSource delegate) {
super(delegate);
delegateHashing = delegate;
}
@Override
public Hash getHash() {
if (value == null) {
value = new HashingSourceHash(delegateHashing.hash().hex());
}
return value;
}
}
// this some funky inner class stuff
// each of these classes is specific to the instance of the HasherHashingSource
// therefore HashingSourceHashes from different parent instances will be not instanceof each other
private class HashingSourceHash extends Hash {
String value;
private HashingSourceHash(String value) {
this.value = value;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof HashingSourceHash)) {
return false;
}
HashingSourceHash objHash = (HashingSourceHash) obj;
if (value != null) {
return value.equals(objHash.value);
} else {
return objHash.value == null ? true : false;
}
}
@Override
public String toString() {
return type + ": " + value;
}
@Override
protected String getStringValue() {
return value;
}
@Override
protected String getType() {
return type;
}
}
@Override
public GeneralHashingSource getHashingSource(Source delegate) {
switch (type) {
case "sha256":
return new HashingSourceGeneralHashingSource(HashingSource.sha256(delegate));
// TODO: support other hash types
}
throw new RuntimeException("Invalid hash type provided");
}
@Override
public Hash getHash(String value) {
return new HashingSourceHash(value);
}
}

View File

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

View File

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

View File

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

View File

@ -1,59 +0,0 @@
package link.infra.packwiz.installer.request;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import link.infra.packwiz.installer.request.handlers.RequestHandlerGithub;
import link.infra.packwiz.installer.request.handlers.RequestHandlerHTTP;
import okio.Source;
public abstract class HandlerManager {
public static List<IRequestHandler> handlers = new ArrayList<IRequestHandler>();
static {
handlers.add(new RequestHandlerGithub());
handlers.add(new RequestHandlerHTTP());
}
public static URI getNewLoc(URI base, URI loc) {
if (loc == null) return null;
if (base != null) {
loc = base.resolve(loc);
}
for (IRequestHandler handler : handlers) {
if (handler.matchesHandler(loc)) {
return handler.getNewLoc(loc);
}
}
return loc;
}
// TODO: What if files are read multiple times??
// Zip handler discards once read, requesting multiple times on other handlers would cause multiple downloads
// Caching system? Copy from already downloaded files?
public static Source getFileSource(URI loc) throws Exception {
for (IRequestHandler handler : handlers) {
if (handler.matchesHandler(loc)) {
Source src = handler.getFileSource(loc);
if (src == null) {
throw new Exception("Couldn't find URI: " + loc.toString());
} else {
return src;
}
}
}
// TODO: specialised exception classes??
throw new Exception("No handler available for URI: " + loc.toString());
}
// github toml resolution
// e.g. https://github.com/comp500/Demagnetize -> demagnetize.toml
// https://github.com/comp500/Demagnetize/blob/master/demagnetize.toml
// To handle "progress", just count tasks, rather than individual progress
// It'll look bad, especially for zip-based things, but it should work fine
}

View File

@ -1,27 +0,0 @@
package link.infra.packwiz.installer.request;
import java.net.URI;
import okio.Source;
/**
* IRequestHandler handles requests for locations specified in modpack metadata.
*/
public interface IRequestHandler {
public boolean matchesHandler(URI loc);
public default URI getNewLoc(URI loc) {
return loc;
}
/**
* Gets the Source for a location. Must be threadsafe.
* It is assumed that each location is read only once for the duration of an IRequestHandler.
* @param loc The location to be read
* @return The Source containing the data of the file
* @throws Exception
*/
public Source getFileSource(URI loc) throws Exception;
}

View File

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

View File

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

View File

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

View File

@ -1,33 +0,0 @@
package link.infra.packwiz.installer.ui;
public class CLIHandler implements IUserInterface {
@Override
public void handleException(Exception e) {
e.printStackTrace();
}
@Override
public void show() {}
@Override
public void submitProgress(InstallProgress progress) {
StringBuilder sb = new StringBuilder();
if (progress.hasProgress) {
sb.append('(');
sb.append(progress.progress);
sb.append('/');
sb.append(progress.progressTotal);
sb.append(") ");
}
sb.append(progress.message);
System.out.println(sb.toString());
}
@Override
public void executeManager(Runnable task) {
task.run();
System.out.println("Finished successfully!");
}
}

View File

@ -1,23 +0,0 @@
package link.infra.packwiz.installer.ui;
public interface IUserInterface {
public void show();
public void handleException(Exception e);
/**
* This might not exit straight away, return after calling this!
*/
public default void handleExceptionAndExit(Exception e) {
handleException(e);
System.exit(1);
};
public default void setTitle(String title) {};
public void submitProgress(InstallProgress progress);
public void executeManager(Runnable task);
}

View File

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

View File

@ -1,189 +0,0 @@
package link.infra.packwiz.installer.ui;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.EventQueue;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JProgressBar;
import javax.swing.UIManager;
import javax.swing.border.EmptyBorder;
public class InstallWindow implements IUserInterface {
private JFrame frmPackwizlauncher;
private JLabel lblProgresslabel;
private JProgressBar progressBar;
private String title = "Updating modpack...";
private SwingWorkerButWithPublicPublish<Void, InstallProgress> worker;
private AtomicBoolean aboutToCrash = new AtomicBoolean();
@Override
public void show() {
EventQueue.invokeLater(new Runnable() {
public void run() {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
InstallWindow.this.initialize();
InstallWindow.this.frmPackwizlauncher.setVisible(true);
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
/**
* Initialize the contents of the frame.
*/
private void initialize() {
frmPackwizlauncher = new JFrame();
frmPackwizlauncher.setTitle(title);
frmPackwizlauncher.setBounds(100, 100, 493, 95);
frmPackwizlauncher.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frmPackwizlauncher.setLocationRelativeTo(null);
JPanel panel = new JPanel();
panel.setBorder(new EmptyBorder(10, 10, 10, 10));
frmPackwizlauncher.getContentPane().add(panel, BorderLayout.CENTER);
panel.setLayout(new BorderLayout(0, 0));
progressBar = new JProgressBar();
progressBar.setIndeterminate(true);
panel.add(progressBar, BorderLayout.CENTER);
lblProgresslabel = new JLabel("Loading...");
panel.add(lblProgresslabel, BorderLayout.SOUTH);
JPanel panel_1 = new JPanel();
panel_1.setBorder(new EmptyBorder(0, 5, 0, 5));
frmPackwizlauncher.getContentPane().add(panel_1, BorderLayout.EAST);
GridBagLayout gbl_panel_1 = new GridBagLayout();
panel_1.setLayout(gbl_panel_1);
JButton btnOptions = new JButton("Options...");
btnOptions.setAlignmentX(Component.CENTER_ALIGNMENT);
GridBagConstraints gbc_btnOptions = new GridBagConstraints();
gbc_btnOptions.gridx = 0;
gbc_btnOptions.gridy = 0;
panel_1.add(btnOptions, gbc_btnOptions);
JButton btnCancel = new JButton("Cancel");
btnCancel.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent arg0) {
if (worker != null) {
worker.cancel(true);
}
frmPackwizlauncher.dispose();
// TODO: show window to ask user what to do
System.out.println("Update process cancelled by user!");
System.exit(1);
}
});
btnCancel.setAlignmentX(Component.CENTER_ALIGNMENT);
GridBagConstraints gbc_btnCancel = new GridBagConstraints();
gbc_btnCancel.gridx = 0;
gbc_btnCancel.gridy = 1;
panel_1.add(btnCancel, gbc_btnCancel);
}
@Override
public void handleException(Exception e) {
e.printStackTrace();
EventQueue.invokeLater(new Runnable() {
public void run() {
JOptionPane.showMessageDialog(null, "An error occurred: \n" + e.getClass().getCanonicalName() + ": " + e.getMessage(), title, JOptionPane.ERROR_MESSAGE);
}
});
}
@Override
public void handleExceptionAndExit(Exception e) {
e.printStackTrace();
// Used to prevent the done() handler of SwingWorker executing if the invokeLater hasn't happened yet
aboutToCrash.set(true);
EventQueue.invokeLater(new Runnable() {
public void run() {
JOptionPane.showMessageDialog(null, "A fatal error occurred: \n" + e.getClass().getCanonicalName() + ": " + e.getMessage(), title, JOptionPane.ERROR_MESSAGE);
System.exit(1);
}
});
}
@Override
public void setTitle(String title) {
this.title = title;
if (frmPackwizlauncher != null) {
EventQueue.invokeLater(new Runnable() {
public void run() {
InstallWindow.this.frmPackwizlauncher.setTitle(title);
}
});
}
}
@Override
public void submitProgress(InstallProgress progress) {
if (worker != null) {
worker.publishPublic(progress);
}
}
@Override
public void executeManager(Runnable task) {
EventQueue.invokeLater(new Runnable() {
public void run() {
worker = new SwingWorkerButWithPublicPublish<Void, InstallProgress>() {
@Override
protected Void doInBackground() throws Exception {
task.run();
return null;
}
@Override
protected void process(List<InstallProgress> chunks) {
// Only process last chunk
if (chunks.size() > 0) {
InstallProgress prog = chunks.get(chunks.size() - 1);
if (prog.hasProgress) {
progressBar.setIndeterminate(false);
progressBar.setValue(prog.progress);
progressBar.setMaximum(prog.progressTotal);
} else {
progressBar.setIndeterminate(true);
progressBar.setValue(0);
}
lblProgresslabel.setText(prog.message);
}
}
@Override
protected void done() {
if (aboutToCrash.get()) {
return;
}
// TODO: a better way to do this?
frmPackwizlauncher.dispose();
System.out.println("Finished successfully!");
System.exit(0);
}
};
worker.execute();
}
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,159 @@
package link.infra.packwiz.installer.metadata.curseforge
import com.google.gson.Gson
import com.google.gson.JsonIOException
import com.google.gson.JsonSyntaxException
import link.infra.packwiz.installer.metadata.IndexFile
import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.target.path.HttpUrlPath
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.closeQuietly
import okio.ByteString.Companion.decodeBase64
import java.nio.charset.StandardCharsets
import kotlin.io.path.absolute
private class GetFilesRequest(val fileIds: List<Int>)
private class GetModsRequest(val modIds: List<Int>)
private class GetFilesResponse {
class CfFile {
var id = 0
var modId = 0
var downloadUrl: String? = null
}
val data = mutableListOf<CfFile>()
}
private class GetModsResponse {
class CfMod {
var id = 0
var name = ""
var links: CfLinks? = null
}
class CfLinks {
var websiteUrl = ""
}
val data = mutableListOf<CfMod>()
}
private const val APIServer = "api.curseforge.com"
// If you fork/derive from packwiz, I request that you obtain your own API key.
private val APIKey = "JDJhJDEwJHNBWVhqblU1N0EzSmpzcmJYM3JVdk92UWk2NHBLS3BnQ2VpbGc1TUM1UGNKL0RYTmlGWWxh".decodeBase64()!!
.string(StandardCharsets.UTF_8)
@Throws(JsonSyntaxException::class, JsonIOException::class)
fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, clientHolder: ClientHolder): List<ExceptionDetails> {
val failures = mutableListOf<ExceptionDetails>()
val fileIdMap = mutableMapOf<Int, List<IndexFile.File>>()
for (mod in mods) {
if (!mod.linkedFile!!.update.contains("curseforge")) {
failures.add(ExceptionDetails(mod.linkedFile!!.name, Exception("Failed to resolve CurseForge metadata: no CurseForge update section")))
continue
}
val fileId = (mod.linkedFile!!.update["curseforge"] as CurseForgeUpdateData).fileId
fileIdMap[fileId] = (fileIdMap[fileId] ?: listOf()) + mod
}
val reqData = GetFilesRequest(fileIdMap.keys.toList())
val req = Request.Builder()
.url("https://${APIServer}/v1/mods/files")
.header("Accept", "application/json")
.header("User-Agent", "packwiz-installer")
.header("X-API-Key", APIKey)
.post(Gson().toJson(reqData, GetFilesRequest::class.java).toRequestBody("application/json".toMediaType()))
.build()
val res = clientHolder.okHttpClient.newCall(req).execute()
if (!res.isSuccessful || res.body == null) {
res.closeQuietly()
failures.add(ExceptionDetails("Other", Exception("Failed to resolve CurseForge metadata for file data: error code ${res.code}")))
return failures
}
val resData = Gson().fromJson(res.body!!.charStream(), GetFilesResponse::class.java)
res.closeQuietly()
val manualDownloadMods = mutableMapOf<Int, List<Int>>()
for (file in resData.data) {
if (!fileIdMap.contains(file.id)) {
failures.add(ExceptionDetails(file.id.toString(),
Exception("Failed to find file from result: ID ${file.id}, Project ID ${file.modId}")))
continue
}
if (file.downloadUrl == null) {
manualDownloadMods[file.modId] = (manualDownloadMods[file.modId] ?: listOf()) + file.id
continue
}
try {
for (indexFile in fileIdMap[file.id]!!) {
indexFile.linkedFile!!.resolvedUpdateData["curseforge"] =
HttpUrlPath(file.downloadUrl!!.toHttpUrl())
}
} catch (e: IllegalArgumentException) {
failures.add(ExceptionDetails(file.id.toString(),
Exception("Failed to parse URL: ${file.downloadUrl} for ID ${file.id}, Project ID ${file.modId}", e)))
}
}
// Some file types don't show up in the API at all! (e.g. shaderpacks)
// Add unresolved files to manualDownloadMods
for ((fileId, indexFiles) in fileIdMap) {
for (file in indexFiles) {
if (file.linkedFile != null) {
if (file.linkedFile!!.resolvedUpdateData["curseforge"] == null) {
val projectId = (file.linkedFile!!.update["curseforge"] as CurseForgeUpdateData).projectId
manualDownloadMods[projectId] = (manualDownloadMods[projectId] ?: listOf()) + fileId
}
}
}
}
if (manualDownloadMods.isNotEmpty()) {
val reqModsData = GetModsRequest(manualDownloadMods.keys.toList())
val reqMods = Request.Builder()
.url("https://${APIServer}/v1/mods")
.header("Accept", "application/json")
.header("User-Agent", "packwiz-installer")
.header("X-API-Key", APIKey)
.post(Gson().toJson(reqModsData, GetModsRequest::class.java).toRequestBody("application/json".toMediaType()))
.build()
val resMods = clientHolder.okHttpClient.newCall(reqMods).execute()
if (!resMods.isSuccessful || resMods.body == null) {
resMods.closeQuietly()
failures.add(ExceptionDetails("Other", Exception("Failed to resolve CurseForge metadata for mod data: error code ${resMods.code}")))
return failures
}
val resModsData = Gson().fromJson(resMods.body!!.charStream(), GetModsResponse::class.java)
resMods.closeQuietly()
for (mod in resModsData.data) {
if (!manualDownloadMods.contains(mod.id)) {
failures.add(ExceptionDetails(mod.name,
Exception("Failed to find project from result: ID ${mod.id}")))
continue
}
for (fileId in manualDownloadMods[mod.id]!!) {
if (!fileIdMap.contains(fileId)) {
failures.add(ExceptionDetails(mod.name,
Exception("Failed to find file from result: file ID $fileId")))
continue
}
for (indexFile in fileIdMap[fileId]!!) {
var modUrl = "${mod.links?.websiteUrl}/files/${fileId}"
failures.add(ExceptionDetails(indexFile.name, Exception("This mod is excluded from the CurseForge API and must be downloaded manually.\n" +
"Please go to ${modUrl} and save this file to ${indexFile.destURI.rebase(packFolder).nioPath.absolute()}"), modUrl))
}
}
}
}
return failures
}

View File

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

View File

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

View File

@ -0,0 +1,22 @@
package link.infra.packwiz.installer.metadata.curseforge
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import java.lang.reflect.Type
class UpdateDeserializer: JsonDeserializer<Map<String, UpdateData>> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): Map<String, UpdateData> {
val out = mutableMapOf<String, UpdateData>()
for ((k, v) in json!!.asJsonObject.entrySet()) {
if (k == "curseforge") {
out[k] = context!!.deserialize(v, CurseForgeUpdateData::class.java)
}
}
return out
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,67 @@
package link.infra.packwiz.installer.request
import okio.IOException
sealed class RequestException: Exception {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
/**
* Internal errors that should not be shown to the user when the code is correct
*/
sealed class Internal: RequestException {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
sealed class HTTP: Internal {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
class NoResponseBody: HTTP("HTTP response in onResponse must have a response body")
class RequestFailed(cause: IOException): HTTP("HTTP request failed", cause)
class IllegalState(cause: IllegalStateException): HTTP("Internal fatal HTTP request error", cause)
}
}
/**
* Errors indicating that the request is malformed
*/
sealed class Validation: RequestException {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
// TODO: move out of RequestException?
class PathContainsNUL(path: String): Validation("Invalid path; contains NUL bytes: ${path.replace("\u0000", "")}")
class PathContainsVolumeLetter(path: String): Validation("Invalid path; contains volume letter: $path")
}
/**
* Errors relating to the response from the server
*/
sealed class Response: RequestException {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
// TODO: fancier way of displaying this?
sealed class HTTP: Response {
val res: okhttp3.Response
constructor(req: okhttp3.Request, res: okhttp3.Response, message: String, cause: Throwable) : super("Failed to make HTTP request to ${req.url}: $message", cause) {
this.res = res
}
constructor(req: okhttp3.Request, res: okhttp3.Response, message: String) : super("Failed to make HTTP request to ${req.url}: $message") {
this.res = res
}
class ErrorCode(req: okhttp3.Request, res: okhttp3.Response): HTTP(req, res, "Non-successful error code from HTTP request: ${res.code}")
}
sealed class File: RequestException {
constructor(message: String, cause: Throwable) : super(message, cause)
constructor(message: String) : super(message)
class FileNotFound(file: String): File("File path not found: $file")
class Other(cause: Throwable): File("Failed to read file", cause)
}
}
}

View File

@ -0,0 +1,23 @@
package link.infra.packwiz.installer.target
import link.infra.packwiz.installer.metadata.hash.Hash
import java.nio.file.Path
data class CachedTarget(
/**
* @see Target.name
*/
val name: String,
/**
* The location where the target was last downloaded to.
* This is used for removing old files when the destination path changes.
* This shouldn't be set to the .disabled path (that is manually appended and checked)
*/
val cachedLocation: Path,
val enabled: Boolean,
val hash: Hash<*>,
/**
* For detecting when a target transitions non-optional -> optional and showing the option selection screen
*/
val isOptional: Boolean
)

View File

@ -0,0 +1,95 @@
package link.infra.packwiz.installer.target
import java.io.IOException
import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes
import kotlin.io.path.relativeTo
data class CachedTargetStatus(val target: CachedTarget, var isValid: Boolean, var markDisabled: Boolean)
fun validate(targets: List<CachedTarget>, baseDir: Path) = runCatching {
val results = targets.map {
CachedTargetStatus(it, isValid = false, markDisabled = false)
}
val tree = buildTree(results, baseDir)
// Efficient file exists checking using directory listing, several orders of magnitude faster than Files.exists calls
Files.walkFileTree(baseDir, setOf(FileVisitOption.FOLLOW_LINKS), Int.MAX_VALUE, object : FileVisitor<Path> {
var currentNode: PathNode<CachedTargetStatus> = tree
override fun preVisitDirectory(dir: Path?, attrs: BasicFileAttributes?): FileVisitResult {
if (dir == null) {
return FileVisitResult.SKIP_SUBTREE
}
val subdirNode = currentNode.subdirs[dir.getName(dir.nameCount - 1)]
return if (subdirNode != null) {
currentNode = subdirNode
FileVisitResult.CONTINUE
} else {
FileVisitResult.SKIP_SUBTREE
}
}
override fun visitFile(file: Path?, attrs: BasicFileAttributes?): FileVisitResult {
if (file == null) {
return FileVisitResult.CONTINUE
}
// TODO: these are relative paths to baseDir
// TODO: strip the .disabled for lookup
val target = currentNode.files[file.getName(file.nameCount - 1)]
if (target != null) {
val disabledFile = file.endsWith(".disabled")
// If a .disabled file and the actual file both exist, mark as invalid if the target is disabled
if ((disabledFile )) {
}
}
return FileVisitResult.CONTINUE
}
@Throws(IOException::class)
override fun visitFileFailed(file: Path?, exc: IOException?): FileVisitResult {
if (exc != null) {
throw exc
}
throw IOException("visitFileFailed called with no exception")
}
@Throws(IOException::class)
override fun postVisitDirectory(dir: Path?, exc: IOException?): FileVisitResult {
if (exc != null) {
throw exc
} else {
val parent = currentNode.parent
if (parent != null) {
currentNode = parent
} else {
throw IOException("Invalid visitor tree structure")
}
return FileVisitResult.CONTINUE
}
}
})
results
}
fun buildTree(targets: List<CachedTargetStatus>, baseDir: Path): PathNode<CachedTargetStatus> {
val root = PathNode<CachedTargetStatus>()
for (target in targets) {
val relPath = target.target.cachedLocation.relativeTo(baseDir)
var node = root
// Traverse all the directory components, except for the last one
for (i in 0 until (relPath.nameCount - 1)) {
node = node.createSubdir(relPath.getName(i))
}
node.files[relPath.getName(relPath.nameCount - 1)] = target
}
return root
}
data class PathNode<T>(val subdirs: MutableMap<Path, PathNode<T>>, val files: MutableMap<Path, T>, val parent: PathNode<T>?) {
constructor() : this(mutableMapOf(), mutableMapOf(), null)
fun createSubdir(nextComponent: Path) = subdirs.getOrPut(nextComponent, { PathNode(mutableMapOf(), mutableMapOf(), this) })
}

View File

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

View File

@ -0,0 +1,13 @@
package link.infra.packwiz.installer.target
enum class OverwriteMode {
/**
* Overwrite the destination with the source file, if the source file has changed.
*/
IF_SRC_CHANGED,
/**
* Never overwrite the destination; if it exists, it should not be written to.
*/
NEVER
}

View File

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

View File

@ -0,0 +1,45 @@
package link.infra.packwiz.installer.target
import link.infra.packwiz.installer.target.path.PackwizPath
// TODO: rename to avoid conflicting with @Target
interface Target {
val src: PackwizPath<*>
val dest: PackwizPath<*>
val validityToken: ValidityToken
/**
* Token interface for types used to compare Target identity. Implementations should use equals to indicate that
* these tokens represent the same file; used to preserve optional target choices between file renames.
*/
interface IdentityToken
/**
* Default implementation of IdentityToken that assumes files are not renamed; so optional choices do not need to
* be preserved across renames.
*/
@JvmInline
value class PathIdentityToken(val path: PackwizPath<*>): IdentityToken
val ident: IdentityToken
get() = PathIdentityToken(dest) // TODO: should use local-rebased path?
/**
* A user-friendly name; defaults to the destination path of the file.
*/
val name: String
get() = dest.filename
val side: Side
get() = Side.BOTH
val overwriteMode: OverwriteMode
get() = OverwriteMode.IF_SRC_CHANGED
interface Optional: Target {
val optional: Boolean
val optionalDefaultValue: Boolean
}
interface OptionalDescribed: Optional {
val optionalDescription: String
}
}

View File

@ -0,0 +1,16 @@
package link.infra.packwiz.installer.target
import link.infra.packwiz.installer.metadata.hash.Hash
/**
* A token used to determine if the source or destination file is changed, and ensure that the destination file is valid.
*/
interface ValidityToken {
// TODO: functions to allow validating this from file metadata, from file, or during the download process
/**
* Default implementation of ValidityToken based on a single hash.
*/
@JvmInline
value class HashValidityToken(val hash: Hash<*>): ValidityToken
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,22 @@
package link.infra.packwiz.installer.task
import kotlin.reflect.KProperty
class CacheManager {
class CacheValue<T> {
operator fun getValue(thisVal: Any?, property: KProperty<*>): T {
TODO("Not yet implemented")
}
operator fun setValue(thisVal: Any?, property: KProperty<*>, value: T) {
TODO("Not yet implemented")
}
}
operator fun <T> get(cacheKey: CacheKey<T>): CacheValue<T> {
return CacheValue()
}
}

View File

@ -0,0 +1,20 @@
package link.infra.packwiz.installer.task
import kotlin.reflect.KMutableProperty0
// TODO: task processing on 1 background thread; actual resolving of values calls out to a thread group
// TODO: progress bar is updated from each of these tasks
// TODO: have everything be lazy so there's no need to determine task ordering upfront? a bit like rust async - task results must be queried to occur
abstract class Task<T>(protected val ctx: TaskContext): TaskInput<T> {
// TODO: lazy wrapper for fallible results
// TODO: multithreaded fanout subclass/helper
protected fun <T> wasUpdated(value: KMutableProperty0<T>, newValue: T): Boolean {
if (value.get() == newValue) {
return false
}
value.set(newValue)
return true
}
}

View File

@ -0,0 +1,6 @@
package link.infra.packwiz.installer.task
/**
* An object for storing results where result and upToDate are calculated simultaneously
*/
data class TaskCombinedResult<T>(val result: T, val upToDate: Boolean)

View File

@ -0,0 +1,12 @@
package link.infra.packwiz.installer.task
import link.infra.packwiz.installer.target.ClientHolder
class TaskContext {
// TODO: thread pools, protocol roots
// TODO: cache management
val cache = CacheManager()
val clients = ClientHolder()
}

View File

@ -0,0 +1,29 @@
package link.infra.packwiz.installer.task
import kotlin.reflect.KProperty
interface TaskInput<T> {
/**
* The value of this task input. May be lazily evaluated; must be threadsafe.
*/
val value: T
/**
* True if the effective value of this input has changed since the task was last run.
* Doesn't require evaluation of the input value; should use cached data if possible.
* May be lazily evaluated; must be threadsafe.
*/
val upToDate: Boolean
operator fun getValue(thisVal: Any?, property: KProperty<*>): T = value
companion object {
fun <T> raw(value: T): TaskInput<T> {
return object: TaskInput<T> {
override val value = value
override val upToDate: Boolean
get() = false
}
}
}
}

View File

@ -0,0 +1,6 @@
package link.infra.packwiz.installer.task.formats.packwizv1
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.target.path.PackwizPath
data class PackwizV1PackFile(val name: String, val indexPath: PackwizPath<*>, val indexHash: Hash<*>)

View File

@ -0,0 +1,48 @@
package link.infra.packwiz.installer.task.formats.packwizv1
import com.google.gson.annotations.SerializedName
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.task.CacheKey
import link.infra.packwiz.installer.task.Task
import link.infra.packwiz.installer.task.TaskCombinedResult
import link.infra.packwiz.installer.task.TaskContext
class PackwizV1PackTomlTask(ctx: TaskContext, val path: PackwizPath<*>): Task<PackwizV1PackFile>(ctx) {
// TODO: make hierarchically defined by caller? - then changing the pack format type doesn't leave junk in the cache
private var cache by ctx.cache[CacheKey<Hash<*>>("packwiz.v1.packtoml.hash", 1)]
private class PackFile {
var name: String? = null
var index: IndexFileLoc? = null
class IndexFileLoc {
var file: String? = null
@SerializedName("hash-format")
var hashFormat: HashFormat<*>? = null
var hash: String? = null
}
var versions: Map<String, String>? = null
}
private val internalResult by lazy {
// TODO: query, parse JSON
val packFile = PackFile()
//Toml().read(InputStreamReader(path.source(ctx.clients).inputStream(), "UTF-8")).to(PackFile::class.java)
val hashFormat = (packFile.index?.hashFormat ?: throw RuntimeException("Hash format required"))
val resolved = PackwizV1PackFile(
packFile.name ?: throw RuntimeException("Name required"), // TODO: better exception handling
path.resolve(packFile.index?.file ?: throw RuntimeException("File required")),
hashFormat.fromString(packFile.index?.hash ?: throw RuntimeException("Hash required"))
)
val hash = hashFormat.fromString("whatever was just read")
TaskCombinedResult(resolved, wasUpdated(::cache, hash))
}
override val value by internalResult::result
override val upToDate by internalResult::upToDate
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,251 @@
package link.infra.packwiz.installer.ui.gui
import link.infra.packwiz.installer.ui.IUserInterface
import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.ui.data.InstallProgress
import link.infra.packwiz.installer.util.Log
import java.awt.EventQueue
import java.util.Timer
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CountDownLatch
import javax.swing.JDialog
import javax.swing.JOptionPane
import javax.swing.UIManager
import kotlin.concurrent.timer
import kotlin.system.exitProcess
class GUIHandler : IUserInterface {
private lateinit var frmPackwizlauncher: InstallWindow
@Volatile
override var optionsButtonPressed = false
set(value) {
optionalSelectedLatch.countDown()
field = value
}
@Volatile
override var cancelButtonPressed = false
set(value) {
optionalSelectedLatch.countDown()
field = value
cancelCallback?.invoke()
}
@Volatile
override var cancelCallback: (() -> Unit)? = null
var okButtonPressed = false
set(value) {
optionalSelectedLatch.countDown()
field = value
}
@Volatile
override var firstInstall = false
override var title = "packwiz-installer"
set(value) {
field = value
EventQueue.invokeLater { frmPackwizlauncher.title = value }
}
init {
EventQueue.invokeAndWait {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
} catch (e: Exception) {
Log.warn("Failed to set look and feel", e)
}
frmPackwizlauncher = InstallWindow(this).apply {
title = this@GUIHandler.title
}
}
}
private val visibleCountdownLatch = CountDownLatch(1)
private val optionalSelectedLatch = CountDownLatch(1)
override fun show() = EventQueue.invokeLater {
frmPackwizlauncher.isVisible = true
visibleCountdownLatch.countDown()
}
override fun dispose() = EventQueue.invokeAndWait {
frmPackwizlauncher.dispose()
}
override fun showErrorAndExit(message: String, e: Exception?): Nothing {
val buttons = arrayOf("Quit", if (firstInstall) "Continue without installing" else "Continue without updating")
if (e != null) {
Log.fatal(message, e)
EventQueue.invokeAndWait {
val result = JOptionPane.showOptionDialog(frmPackwizlauncher,
"$message: $e",
title,
JOptionPane.YES_NO_OPTION, JOptionPane.ERROR_MESSAGE, null, buttons, buttons[0])
if (result == 1) {
Log.info("User selected to continue without installing/updating, exiting with code 0...")
exitProcess(0)
} else {
Log.info("User selected to quit, exiting with code 1...")
exitProcess(1)
}
}
} else {
Log.fatal(message)
EventQueue.invokeAndWait {
val result = JOptionPane.showOptionDialog(frmPackwizlauncher,
message,
title,
JOptionPane.YES_NO_OPTION, JOptionPane.ERROR_MESSAGE, null, buttons, buttons[0])
if (result == 1) {
Log.info("User selected to continue without installing/updating, exiting with code 0...")
exitProcess(0)
} else {
Log.info("User selected to quit, exiting with code 1...")
exitProcess(1)
}
}
}
exitProcess(1)
}
override fun submitProgress(progress: InstallProgress) {
val sb = StringBuilder()
if (progress.hasProgress) {
sb.append('(')
sb.append(progress.progress)
sb.append('/')
sb.append(progress.progressTotal)
sb.append(") ")
}
sb.append(progress.message)
Log.info(sb.toString())
EventQueue.invokeLater {
frmPackwizlauncher.displayProgress(progress)
}
}
override fun showOptions(options: List<IOptionDetails>): Boolean {
val future = CompletableFuture<Boolean>()
EventQueue.invokeAndWait {
if (options.isEmpty()) {
JOptionPane.showMessageDialog(null,
"This modpack has no optional mods!",
"Optional mods", JOptionPane.INFORMATION_MESSAGE)
future.complete(false)
} else {
OptionsSelectWindow(options, future, frmPackwizlauncher).apply {
defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
isVisible = true
}
}
}
return future.get()
}
override fun showExceptions(exceptions: List<ExceptionDetails>, numTotal: Int, allowsIgnore: Boolean): ExceptionListResult {
val future = CompletableFuture<ExceptionListResult>()
EventQueue.invokeLater {
ExceptionListWindow(exceptions, future, numTotal, allowsIgnore, frmPackwizlauncher).apply {
defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE
isVisible = true
}
}
return future.get()
}
override fun disableOptionsButton(hasOptions: Boolean) = EventQueue.invokeLater {
frmPackwizlauncher.disableOptionsButton(hasOptions)
}
override fun showCancellationDialog(): IUserInterface.CancellationResult {
val future = CompletableFuture<IUserInterface.CancellationResult>()
EventQueue.invokeLater {
val buttons = arrayOf("Quit", "Ignore")
val result = JOptionPane.showOptionDialog(frmPackwizlauncher,
"The installation was cancelled. Would you like to quit the game, or ignore the update and start the game?",
"Cancelled installation",
JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, null, buttons, buttons[0])
future.complete(if (result == 0) IUserInterface.CancellationResult.QUIT else IUserInterface.CancellationResult.CONTINUE)
}
return future.get()
}
override fun showUpdateConfirmationDialog(oldVersions: List<Pair<String, String?>>, newVersions: List<Pair<String, String?>>): IUserInterface.UpdateConfirmationResult {
assert(newVersions.isNotEmpty())
val future = CompletableFuture<IUserInterface.UpdateConfirmationResult>()
EventQueue.invokeLater {
val oldVersIndex = oldVersions.map { it.first to it.second }.toMap()
val newVersIndex = newVersions.map { it.first to it.second }.toMap()
val message = StringBuilder()
message.append("<html>" +
"This modpack uses newer versions of the following:<br>" +
"<ul>")
for (oldVer in oldVersions) {
val correspondingNewVer = newVersIndex[oldVer.first]
message.append("<li>")
message.append(oldVer.first.replaceFirstChar { it.uppercase() })
message.append(": <font color=${if (oldVer.second != correspondingNewVer) "#ff0000" else "#000000"}>")
message.append(oldVer.second ?: "Not found")
message.append("</font></li>")
}
message.append("</ul>")
message.append("New versions:" +
"<ul>")
for (newVer in newVersions) {
val correspondingOldVer = oldVersIndex[newVer.first]
message.append("<li>")
message.append(newVer.first.replaceFirstChar { it.uppercase() })
message.append(": <font color=${if (newVer.second != correspondingOldVer) "#00ff00" else "#000000"}>")
message.append(newVer.second ?: "Not found")
message.append("</font></li>")
}
message.append("</ul><br>" +
"Would you like to update the versions, launch without updating, or cancel the launch?")
val options = arrayOf("Cancel", "Continue anyways", "Update")
val result = JOptionPane.showOptionDialog(frmPackwizlauncher, message,
"Updating MultiMC versions",
JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE, null, options, options[2])
future.complete(
when (result) {
JOptionPane.CLOSED_OPTION, 0 -> IUserInterface.UpdateConfirmationResult.CANCELLED
1 -> IUserInterface.UpdateConfirmationResult.CONTINUE
2 -> IUserInterface.UpdateConfirmationResult.UPDATE
else -> IUserInterface.UpdateConfirmationResult.CANCELLED
}
)
}
return future.get()
}
override fun awaitOptionalButton(showCancel: Boolean, timeout: Long) {
EventQueue.invokeAndWait {
frmPackwizlauncher.showOk(!showCancel)
}
visibleCountdownLatch.await()
var closeTimer: Timer? = null
if (timeout >= 0) {
var count = 0
closeTimer = timer("timeout", true, 0, 1000) {
if (count >= timeout) {
optionalSelectedLatch.countDown()
cancel()
} else {
frmPackwizlauncher.timeoutOk(timeout - count)
count += 1
}
};
}
optionalSelectedLatch.await()
closeTimer?.cancel()
EventQueue.invokeLater {
frmPackwizlauncher.hideOk()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

19
src/main/proguard.txt Normal file
View File

@ -0,0 +1,19 @@
-dontoptimize
-dontobfuscate
# Used by Okio and OkHttp
-dontwarn org.codehaus.mojo.animal_sniffer.*
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-keep class !link.infra.packwiz.installer.deps.**,link.infra.packwiz.installer.** { *; }
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep @interface kotlin.Metadata { *; }
-renamesourcefileattribute SourceFile
-keepattributes *Annotation*,SourceFile,LineNumberTable,Signature

View File

@ -0,0 +1,264 @@
# Licenses
packwiz-installer itself is under the MIT license ([Source](https://github.com/packwiz/packwiz-installer)), except for Murmur2Lib and bundled dependencies as follows:
- Murmur2Lib: Apache 2.0 ([Source](https://github.com/prasanthj/hasher/blob/master/src/main/java/hasher/Murmur2.java))
- Copyright 2014 Prasanth Jayachandran
- Google Gson 2.9.0: Apache 2.0 ([Source](https://github.com/google/gson))
- Copyright 2008 Google Inc.
- Okio 3.1.0: Apache 2.0 ([Source](https://github.com/square/okio/))
- Copyright 2013 Square, Inc.
- Commons CLI 1.5: Apache 2.0 ([Source](http://commons.apache.org/proper/commons-cli/))
- Jetbrains Annotations 13.0: Apache 2.0 ([Source](https://github.com/JetBrains/java-annotations))
- Copyright 2000-2016 JetBrains s.r.o.
- Kotlin Standard Library 1.7.10: Apache 2.0 ([Source](https://github.com/JetBrains/kotlin))
- Copyright 2010-2020 JetBrains s.r.o and respective authors and developers
- 4koma 1.1.0: MIT ([Source](https://github.com/valderman/4koma))
- Copyright (c) 2021 Anton Ekblad
## Associated notices
### Commons CLI
Apache Commons CLI
Copyright 2001-2017 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
## Full license texts
### MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
### Apache 2.0
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
### ISC
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.