1
0
mirror of https://github.com/packwiz/packwiz-installer.git synced 2025-04-27 00:36:31 +02:00

Compare commits

...

17 Commits
v0.5.1 ... main

Author SHA1 Message Date
Katherine
7420866dfc
MultiMC metadata: Support updating NeoForge version () 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 ()
* 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 ) 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 ) 2023-01-17 05:21:20 +00:00
comp500
ad951b9b44 Download mods for both sides with --side both (fixes ) 2023-01-17 02:33:07 +00:00
Eric Richter
4e415c1e1a
Add option to automatically close after a user-supplied amount of time ()
* 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 ) 2023-01-15 02:23:18 +00:00
comp500
fa9fe18215 Fix manual link resolution for files not visible in CF API (fixes ) 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
16 changed files with 270 additions and 129 deletions

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

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

@ -1,6 +1,9 @@
name: Java Gradle Snapshot name: Java Gradle Snapshot
on: ["push", "pull_request"] on:
push:
branches:
- 'main'
jobs: jobs:
build: build:

@ -21,6 +21,7 @@ import java.nio.file.StandardCopyOption
internal class DownloadTask private constructor(val metadata: IndexFile.File, val index: IndexFile, private val downloadSide: Side) : IOptionDetails { internal class DownloadTask private constructor(val metadata: IndexFile.File, val index: IndexFile, private val downloadSide: Side) : IOptionDetails {
var cachedFile: ManifestFile.File? = null var cachedFile: ManifestFile.File? = null
private set
private var err: Exception? = null private var err: Exception? = null
val exceptionDetails get() = err?.let { e -> ExceptionDetails(name, e) } val exceptionDetails get() = err?.let { e -> ExceptionDetails(name, e) }
@ -28,16 +29,30 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
fun failed() = err != null fun failed() = err != null
var alreadyUpToDate = false var alreadyUpToDate = false
private set
private var metadataRequired = true private var metadataRequired = true
private var invalidated = false private var invalidated = false
// If file is new or isOptional changed to true, the option needs to be presented again // If file is new or isOptional changed to true, the option needs to be presented again
private var newOptional = true 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 val isOptional get() = metadata.linkedFile?.option?.optional ?: false
fun isNewOptional() = isOptional && newOptional fun isNewOptional() = isOptional && newOptional
fun correctSide() = metadata.linkedFile?.side?.hasSide(downloadSide) ?: true fun correctSide() = metadata.linkedFile?.side?.let { downloadSide.hasSide(it) } ?: true
override val name get() = metadata.name override val name get() = metadata.name
@ -76,6 +91,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
if (currHash == cachedFile.hash) { // Already up to date if (currHash == cachedFile.hash) { // Already up to date
alreadyUpToDate = true alreadyUpToDate = true
metadataRequired = false metadataRequired = false
completionStatus = CompletionStatus.ALREADY_EXISTS_CACHED
} }
} }
if (cachedFile.isOptional) { if (cachedFile.isOptional) {
@ -109,12 +125,12 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
cachedFile.optionValue = linkedFile.option.defaultValue cachedFile.optionValue = linkedFile.option.defaultValue
} }
} }
}
cachedFile.isOptional = isOptional cachedFile.isOptional = isOptional
cachedFile.onlyOtherSide = !correctSide() cachedFile.onlyOtherSide = !correctSide()
} }
} }
} }
}
/** /**
* Check if the file in the destination location is already valid * Check if the file in the destination location is already valid
@ -143,6 +159,7 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
fileSource.buffer().readAll(blackholeSink()) fileSource.buffer().readAll(blackholeSink())
if (hash == fileSource.hash) { if (hash == fileSource.hash) {
alreadyUpToDate = true alreadyUpToDate = true
completionStatus = CompletionStatus.ALREADY_EXISTS_VALIDATED
// Update the manifest file // Update the manifest file
cachedFile = (cachedFile ?: ManifestFile.File()).also { cachedFile = (cachedFile ?: ManifestFile.File()).also {
@ -181,10 +198,18 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
if (it.cachedLocation != null) { if (it.cachedLocation != null) {
// Ensure wrong-side or optional false files are removed // Ensure wrong-side or optional false files are removed
try { try {
Files.deleteIfExists(it.cachedLocation!!.nioPath) 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) { } catch (e: IOException) {
Log.warn("Failed to delete file", e) Log.warn("Failed to delete file", e)
} }
} else {
completionStatus =
if (correctSide()) { CompletionStatus.SKIPPED_DISABLED }
else { CompletionStatus.SKIPPED_WRONG_SIDE }
} }
it.cachedLocation = null it.cachedLocation = null
return return
@ -284,6 +309,8 @@ internal class DownloadTask private constructor(val metadata: IndexFile.File, va
} }
} }
} }
completionStatus = CompletionStatus.DOWNLOADED
} }
companion object { companion object {

@ -48,6 +48,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
val modLoaders = hashMapOf( val modLoaders = hashMapOf(
"net.minecraft" to "minecraft", "net.minecraft" to "minecraft",
"net.minecraftforge" to "forge", "net.minecraftforge" to "forge",
"net.neoforged" to "neoforge",
"net.fabricmc.fabric-loader" to "fabric", "net.fabricmc.fabric-loader" to "fabric",
"org.quiltmc.quilt-loader" to "quilt", "org.quiltmc.quilt-loader" to "quilt",
"com.mumfrey.liteloader" to "liteloader" "com.mumfrey.liteloader" to "liteloader"
@ -59,6 +60,7 @@ class LauncherUtils internal constructor(private val opts: UpdateManager.Options
"org.lwjgl" to -1, "org.lwjgl" to -1,
"org.lwjgl3" to -1, "org.lwjgl3" to -1,
"net.minecraftforge" to 5, "net.minecraftforge" to 5,
"net.neoforged" to 5,
"net.fabricmc.fabric-loader" to 10, "net.fabricmc.fabric-loader" to 10,
"org.quiltmc.quilt-loader" to 10, "org.quiltmc.quilt-loader" to 10,
"com.mumfrey.liteloader" to 10, "com.mumfrey.liteloader" to 10,

@ -101,12 +101,15 @@ class Main(args: Array<String>) {
cmd.getOptionValue("multimc-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath("..".toPath()) cmd.getOptionValue("multimc-folder")?.let{ PackwizFilePath(it.toPath()) } ?: PackwizFilePath("..".toPath())
} }
val manifestFile = ui.wrap("Invalid manifest file path") { val manifestFile = ui.wrap("Invalid manifest file path") {
packFolder / (cmd.getOptionValue("meta-file") ?: "manifest.json") packFolder / (cmd.getOptionValue("meta-file") ?: "packwiz.json")
}
val timeout = ui.wrap("Invalid timeout value") {
cmd.getOptionValue("timeout")?.toLong() ?: 10
} }
// Start update process! // Start update process!
try { try {
UpdateManager(UpdateManager.Options(packFile, manifestFile, packFolder, multimcFolder, side), ui) UpdateManager(UpdateManager.Options(packFile, manifestFile, packFolder, multimcFolder, side, timeout), ui)
} catch (e: Exception) { } catch (e: Exception) {
ui.showErrorAndExit("Update process failed", e) ui.showErrorAndExit("Update process failed", e)
} }
@ -123,6 +126,7 @@ class Main(args: Array<String>) {
options.addOption(null, "pack-folder", true, "Folder to install the pack to (defaults to the JAR directory)") 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, "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(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? // TODO: link these somehow so they're only defined once?
@ -135,6 +139,12 @@ class Main(args: Array<String>) {
options.addOption("g", "no-gui", false, "Don't display a GUI to show update progress") options.addOption("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! 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! // Actual main() is in RequiresBootstrap!

@ -23,7 +23,6 @@ import link.infra.packwiz.installer.ui.IUserInterface.ExceptionListResult
import link.infra.packwiz.installer.ui.data.InstallProgress import link.infra.packwiz.installer.ui.data.InstallProgress
import link.infra.packwiz.installer.util.Log import link.infra.packwiz.installer.util.Log
import okio.buffer import okio.buffer
import java.io.FileWriter
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader import java.io.InputStreamReader
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@ -48,7 +47,8 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
val manifestFile: PackwizFilePath, val manifestFile: PackwizFilePath,
val packFolder: PackwizFilePath, val packFolder: PackwizFilePath,
val multimcFolder: PackwizFilePath, val multimcFolder: PackwizFilePath,
val side: Side val side: Side,
val timeout: Long,
) )
// TODO: make this return a value based on results? // TODO: make this return a value based on results?
@ -127,8 +127,11 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
ui.submitProgress(InstallProgress("Checking local files...")) ui.submitProgress(InstallProgress("Checking local files..."))
// Invalidation checking must be done here, as it must happen before pack/index hashes are checked // If the side changes, invalidate EVERYTHING (even when the index hasn't changed)
val invalidateAll = opts.side != manifest.cachedSide
val invalidatedUris: MutableList<PackwizFilePath> = ArrayList() 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) { for ((fileUri, file) in manifest.cachedFiles) {
// ignore onlyOtherSide files // ignore onlyOtherSide files
if (file.onlyOtherSide) { if (file.onlyOtherSide) {
@ -157,12 +160,13 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
// todo: --force? // todo: --force?
ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1)) ui.submitProgress(InstallProgress("Modpack is already up to date!", 1, 1))
if (manifest.cachedFiles.any { it.value.isOptional }) { if (manifest.cachedFiles.any { it.value.isOptional }) {
ui.awaitOptionalButton(false) ui.awaitOptionalButton(false, opts.timeout)
} }
if (!ui.optionsButtonPressed) { if (!ui.optionsButtonPressed) {
return return
} }
} }
}
Log.info("Modpack name: ${pf.name}") Log.info("Modpack name: ${pf.name}")
@ -177,6 +181,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
pf.index.hashFormat, pf.index.hashFormat,
manifest, manifest,
invalidatedUris, invalidatedUris,
invalidateAll,
clientHolder clientHolder
) )
} catch (e1: Exception) { } catch (e1: Exception) {
@ -196,17 +201,18 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
manifest.cachedSide = opts.side manifest.cachedSide = opts.side
try { try {
FileWriter(opts.manifestFile.nioPath.toFile()).use { writer -> gson.toJson(manifest, writer) } Files.newBufferedWriter(opts.manifestFile.nioPath, StandardCharsets.UTF_8).use { writer -> gson.toJson(manifest, writer) }
} catch (e: IOException) { } catch (e: IOException) {
ui.showErrorAndExit("Failed to save local manifest file", e) ui.showErrorAndExit("Failed to save local manifest file", e)
} }
} }
private fun processIndex(indexUri: PackwizPath<*>, indexHash: Hash<*>, hashFormat: HashFormat<*>, manifest: ManifestFile, invalidatedFiles: List<PackwizFilePath>, clientHolder: ClientHolder) { 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()) { if (manifest.indexFileHash == indexHash && invalidatedFiles.isEmpty()) {
ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1)) ui.submitProgress(InstallProgress("Modpack files are already up to date!", 1, 1))
if (manifest.cachedFiles.any { it.value.isOptional }) { if (manifest.cachedFiles.any { it.value.isOptional }) {
ui.awaitOptionalButton(false) ui.awaitOptionalButton(false, opts.timeout)
} }
if (!ui.optionsButtonPressed) { if (!ui.optionsButtonPressed) {
return return
@ -216,6 +222,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
return return
} }
} }
}
manifest.indexFileHash = indexHash manifest.indexFileHash = indexHash
val indexFileSource = try { val indexFileSource = try {
@ -240,31 +247,17 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
} }
ui.submitProgress(InstallProgress("Checking local files...")) ui.submitProgress(InstallProgress("Checking local files..."))
// TODO: use kotlin filtering/FP rather than an iterator?
val it: MutableIterator<Map.Entry<PackwizFilePath, ManifestFile.File>> = manifest.cachedFiles.entries.iterator() val it: MutableIterator<Map.Entry<PackwizFilePath, ManifestFile.File>> = manifest.cachedFiles.entries.iterator()
while (it.hasNext()) { while (it.hasNext()) {
val (uri, file) = it.next() val (uri, file) = it.next()
if (file.cachedLocation != null) { if (file.cachedLocation != null) {
var alreadyDeleted = false
// Delete if option value has been set to false
if (file.isOptional && !file.optionValue) {
try {
Files.deleteIfExists(file.cachedLocation!!.nioPath)
} catch (e: IOException) {
Log.warn("Failed to delete optional disabled file", e)
}
// Set to null, as it doesn't exist anymore
file.cachedLocation = null
alreadyDeleted = true
}
if (indexFile.files.none { it.file.rebase(opts.packFolder) == uri }) { // File has been removed from the index if (indexFile.files.none { it.file.rebase(opts.packFolder) == uri }) { // File has been removed from the index
if (!alreadyDeleted) {
try { try {
Files.deleteIfExists(file.cachedLocation!!.nioPath) Files.deleteIfExists(file.cachedLocation!!.nioPath)
} catch (e: IOException) { } catch (e: IOException) {
Log.warn("Failed to delete file removed from index", e) Log.warn("Failed to delete file removed from index", e)
} }
} Log.info("Deleted ${file.cachedLocation!!.filename} (removed from pack)")
it.remove() it.remove()
} }
} }
@ -281,9 +274,6 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
Log.warn("Index is empty!") Log.warn("Index is empty!")
} }
val tasks = createTasksFromIndex(indexFile, opts.side) val tasks = createTasksFromIndex(indexFile, opts.side)
// If the side changes, invalidate EVERYTHING just in case
// Might not be needed, but done just to be safe
val invalidateAll = opts.side != manifest.cachedSide
if (invalidateAll) { if (invalidateAll) {
Log.info("Side changed, invalidating all mods") Log.info("Side changed, invalidating all mods")
} }
@ -338,7 +328,7 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
if (!ui.optionsButtonPressed) { if (!ui.optionsButtonPressed) {
// TODO: this is so ugly // TODO: this is so ugly
ui.submitProgress(InstallProgress("Reconfigure optional mods?", 0,1)) ui.submitProgress(InstallProgress("Reconfigure optional mods?", 0,1))
ui.awaitOptionalButton(true) ui.awaitOptionalButton(true, opts.timeout)
if (ui.cancelButtonPressed) { if (ui.cancelButtonPressed) {
showCancellationDialog() showCancellationDialog()
return return
@ -397,7 +387,16 @@ class UpdateManager internal constructor(private val opts: Options, val ui: IUse
val progress = if (exDetails != null) { val progress = if (exDetails != null) {
"Failed to download ${exDetails.name}: ${exDetails.exception.message}" "Failed to download ${exDetails.name}: ${exDetails.exception.message}"
} else { } else {
"Downloaded ${task.name}" 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)) ui.submitProgress(InstallProgress(progress, i + 1, tasks.size))

@ -49,14 +49,15 @@ private val APIKey = "JDJhJDEwJHNBWVhqblU1N0EzSmpzcmJYM3JVdk92UWk2NHBLS3BnQ2VpbG
@Throws(JsonSyntaxException::class, JsonIOException::class) @Throws(JsonSyntaxException::class, JsonIOException::class)
fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, clientHolder: ClientHolder): List<ExceptionDetails> { fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, clientHolder: ClientHolder): List<ExceptionDetails> {
val failures = mutableListOf<ExceptionDetails>() val failures = mutableListOf<ExceptionDetails>()
val fileIdMap = mutableMapOf<Int, IndexFile.File>() val fileIdMap = mutableMapOf<Int, List<IndexFile.File>>()
for (mod in mods) { for (mod in mods) {
if (!mod.linkedFile!!.update.contains("curseforge")) { if (!mod.linkedFile!!.update.contains("curseforge")) {
failures.add(ExceptionDetails(mod.linkedFile!!.name, Exception("Failed to resolve CurseForge metadata: no CurseForge update section"))) failures.add(ExceptionDetails(mod.linkedFile!!.name, Exception("Failed to resolve CurseForge metadata: no CurseForge update section")))
continue continue
} }
fileIdMap[(mod.linkedFile!!.update["curseforge"] as CurseForgeUpdateData).fileId] = mod val fileId = (mod.linkedFile!!.update["curseforge"] as CurseForgeUpdateData).fileId
fileIdMap[fileId] = (fileIdMap[fileId] ?: listOf()) + mod
} }
val reqData = GetFilesRequest(fileIdMap.keys.toList()) val reqData = GetFilesRequest(fileIdMap.keys.toList())
@ -77,7 +78,7 @@ fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, c
val resData = Gson().fromJson(res.body!!.charStream(), GetFilesResponse::class.java) val resData = Gson().fromJson(res.body!!.charStream(), GetFilesResponse::class.java)
res.closeQuietly() res.closeQuietly()
val manualDownloadMods = mutableMapOf<Int, Pair<IndexFile.File, Int>>() val manualDownloadMods = mutableMapOf<Int, List<Int>>()
for (file in resData.data) { for (file in resData.data) {
if (!fileIdMap.contains(file.id)) { if (!fileIdMap.contains(file.id)) {
failures.add(ExceptionDetails(file.id.toString(), failures.add(ExceptionDetails(file.id.toString(),
@ -85,18 +86,33 @@ fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, c
continue continue
} }
if (file.downloadUrl == null) { if (file.downloadUrl == null) {
manualDownloadMods[file.modId] = Pair(fileIdMap[file.id]!!, file.id) manualDownloadMods[file.modId] = (manualDownloadMods[file.modId] ?: listOf()) + file.id
continue continue
} }
try { try {
fileIdMap[file.id]!!.linkedFile!!.resolvedUpdateData["curseforge"] = for (indexFile in fileIdMap[file.id]!!) {
indexFile.linkedFile!!.resolvedUpdateData["curseforge"] =
HttpUrlPath(file.downloadUrl!!.toHttpUrl()) HttpUrlPath(file.downloadUrl!!.toHttpUrl())
}
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
failures.add(ExceptionDetails(file.id.toString(), failures.add(ExceptionDetails(file.id.toString(),
Exception("Failed to parse URL: ${file.downloadUrl} for ID ${file.id}, Project ID ${file.modId}", e))) 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()) { if (manualDownloadMods.isNotEmpty()) {
val reqModsData = GetModsRequest(manualDownloadMods.keys.toList()) val reqModsData = GetModsRequest(manualDownloadMods.keys.toList())
val reqMods = Request.Builder() val reqMods = Request.Builder()
@ -123,9 +139,19 @@ fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, c
continue continue
} }
val modFile = manualDownloadMods[mod.id]!! for (fileId in manualDownloadMods[mod.id]!!) {
failures.add(ExceptionDetails(mod.name, Exception("This mod is excluded from the CurseForge API and must be downloaded manually.\n" + if (!fileIdMap.contains(fileId)) {
"Please go to ${mod.links?.websiteUrl}/files/${modFile.second} and save this file to ${modFile.first.destURI.rebase(packFolder).nioPath.absolute()}"))) 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))
}
}
} }
} }

@ -21,7 +21,14 @@ data class Hash<T>(val type: HashFormat<T>, val value: T) {
object UInt: Encoding<kotlin.UInt> { object UInt: Encoding<kotlin.UInt> {
override fun encodeToString(value: kotlin.UInt) = value.toString() override fun encodeToString(value: kotlin.UInt) = value.toString()
override fun decodeFromString(str: String) = str.toUInt() 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()
}
} }
} }

@ -8,9 +8,12 @@ import java.net.SocketTimeoutException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class ClientHolder { 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? // TODO: a button to increase timeouts temporarily when retrying? manual retry button?
val okHttpClient by lazy { OkHttpClient.Builder() val okHttpClient by lazy { OkHttpClient.Builder()
// Retry requests up to 3 times, increasing the timeouts slightly if it failed // Retry requests according to retryTimes list
.addInterceptor { .addInterceptor {
val req = it.request() val req = it.request()
@ -24,20 +27,20 @@ class ClientHolder {
} }
var tryCount = 0 var tryCount = 0
while (res == null && tryCount < 3) { while (res == null && tryCount < retryTimes.size) {
tryCount++ Log.info("OkHttp connection to ${req.url} timed out; retrying... (${tryCount + 1}/${retryTimes.size})")
Log.info("OkHttp connection to ${req.url} timed out; retrying... ($tryCount/3)")
val longerTimeoutChain = it val longerTimeoutChain = it
.withConnectTimeout(10 * tryCount, TimeUnit.SECONDS) .withConnectTimeout(retryTimes[tryCount], TimeUnit.SECONDS)
.withReadTimeout(10 * tryCount, TimeUnit.SECONDS) .withReadTimeout(retryTimes[tryCount], TimeUnit.SECONDS)
.withWriteTimeout(10 * tryCount, TimeUnit.SECONDS) .withWriteTimeout(retryTimes[tryCount], TimeUnit.SECONDS)
try { try {
res = longerTimeoutChain.proceed(req) res = longerTimeoutChain.proceed(req)
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
lastException = e lastException = e
} }
tryCount++
} }
res ?: throw lastException!! res ?: throw lastException!!

@ -4,42 +4,29 @@ import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper import cc.ekblad.toml.tomlMapper
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
enum class Side { enum class Side(sideName: String) {
@SerializedName("client") @SerializedName("client")
CLIENT("client"), CLIENT("client"),
@SerializedName("server") @SerializedName("server")
SERVER("server"), SERVER("server"),
@SerializedName("both") @SerializedName("both")
@Suppress("unused") @Suppress("unused")
BOTH("both", arrayOf(CLIENT, SERVER)); BOTH("both") {
override fun hasSide(tSide: Side): Boolean {
return true
}
};
private val sideName: String private val sideName: String
private val depSides: Array<Side>?
constructor(sideName: String) { init {
this.sideName = sideName.lowercase() this.sideName = sideName.lowercase()
depSides = null
}
constructor(sideName: String, depSides: Array<Side>) {
this.sideName = sideName.lowercase()
this.depSides = depSides
} }
override fun toString() = sideName override fun toString() = sideName
fun hasSide(tSide: Side): Boolean { open fun hasSide(tSide: Side): Boolean {
if (this == tSide) { return this == tSide || tSide == BOTH
return true
}
if (depSides != null) {
for (depSide in depSides) {
if (depSide == tSide) {
return true
}
}
}
return false
} }
companion object { companion object {

@ -23,9 +23,12 @@ interface IUserInterface {
fun showCancellationDialog(): CancellationResult = CancellationResult.QUIT fun showCancellationDialog(): CancellationResult = CancellationResult.QUIT
fun showUpdateConfirmationDialog(oldVersions: List<Pair<String, String?>>, newVersions: List<Pair<String, String?>>): UpdateConfirmationResult = UpdateConfirmationResult.CANCELLED 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) fun awaitOptionalButton(showCancel: Boolean, timeout: Long)
enum class ExceptionListResult { enum class ExceptionListResult {
CONTINUE, CANCEL, IGNORE CONTINUE, CANCEL, IGNORE

@ -63,7 +63,7 @@ class CLIHandler : IUserInterface {
return ExceptionListResult.CANCEL return ExceptionListResult.CANCEL
} }
override fun awaitOptionalButton(showCancel: Boolean) { override fun awaitOptionalButton(showCancel: Boolean, timeout: Long) {
// Do nothing // Do nothing
} }
} }

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

@ -1,5 +1,6 @@
package link.infra.packwiz.installer.ui.gui 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.IUserInterface
import link.infra.packwiz.installer.ui.data.ExceptionDetails import link.infra.packwiz.installer.ui.data.ExceptionDetails
import java.awt.BorderLayout import java.awt.BorderLayout
@ -24,6 +25,24 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
fun getExceptionAt(index: Int) = details[index].exception 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. * Create the dialog.
*/ */
@ -112,6 +131,19 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
this@ExceptionListWindow.dispose() 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) }, BorderLayout.EAST)
// Errored label // Errored label
@ -122,16 +154,8 @@ class ExceptionListWindow(eList: List<ExceptionDetails>, future: CompletableFutu
// Left buttons // Left buttons
add(JPanel().apply { add(JPanel().apply {
add(JButton("Report issue").apply { add(JButton("Report issue").apply {
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
addActionListener { addActionListener {
try { openUrl("https://github.com/packwiz/packwiz-installer/issues/new")
Desktop.getDesktop().browse(URI("https://github.com/packwiz/packwiz-installer/issues/new"))
} catch (e: IOException) {
// lol the button just won't work i guess
} catch (e: URISyntaxException) {}
}
} else {
isEnabled = false
} }
}) })
}, BorderLayout.WEST) }, BorderLayout.WEST)

@ -7,11 +7,13 @@ import link.infra.packwiz.installer.ui.data.IOptionDetails
import link.infra.packwiz.installer.ui.data.InstallProgress import link.infra.packwiz.installer.ui.data.InstallProgress
import link.infra.packwiz.installer.util.Log import link.infra.packwiz.installer.util.Log
import java.awt.EventQueue import java.awt.EventQueue
import java.util.Timer
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import javax.swing.JDialog import javax.swing.JDialog
import javax.swing.JOptionPane import javax.swing.JOptionPane
import javax.swing.UIManager import javax.swing.UIManager
import kotlin.concurrent.timer
import kotlin.system.exitProcess import kotlin.system.exitProcess
class GUIHandler : IUserInterface { class GUIHandler : IUserInterface {
@ -220,12 +222,28 @@ class GUIHandler : IUserInterface {
return future.get() return future.get()
} }
override fun awaitOptionalButton(showCancel: Boolean) { override fun awaitOptionalButton(showCancel: Boolean, timeout: Long) {
EventQueue.invokeAndWait { EventQueue.invokeAndWait {
frmPackwizlauncher.showOk(!showCancel) frmPackwizlauncher.showOk(!showCancel)
} }
visibleCountdownLatch.await() 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() optionalSelectedLatch.await()
closeTimer?.cancel()
EventQueue.invokeLater { EventQueue.invokeLater {
frmPackwizlauncher.hideOk() frmPackwizlauncher.hideOk()
} }

@ -121,4 +121,8 @@ class InstallWindow(private val handler: GUIHandler) : JFrame() {
} }
buttonsPanel.revalidate() buttonsPanel.revalidate()
} }
fun timeoutOk(remaining: Long) {
btnOk.text = "Continue ($remaining)"
}
} }