diff --git a/src/main/java/com/osiris/autoplug/client/configs/ModsConfig.java b/src/main/java/com/osiris/autoplug/client/configs/ModsConfig.java index 6a9870ab..91cd45af 100644 --- a/src/main/java/com/osiris/autoplug/client/configs/ModsConfig.java +++ b/src/main/java/com/osiris/autoplug/client/configs/ModsConfig.java @@ -44,6 +44,7 @@ public ModsConfig() throws IOException, DuplicateKeyException, YamlReaderExcepti "If there are mods that weren't found by the search-algorithm, you can add an id (spigot or bukkit) and a custom link (optional & must be a static link to the latest mod jar).\n" + "modrinth-id: Is the 'Project-ID' and can be found on the mods modrinth site inside of the 'About' box, under 'Technical Information' at the bottom left.\n" + "curseforge-id: Is also called 'Project-ID' and can be found on the mods curseforge site inside of the 'About' box at the right.\n" + + "steam-workshop-id: The Steam Workshop item id read from the mods metadata. Requires server-updater.software to be a numeric Steam app-id.\n" + "ignore-content-type: If true, does not check if the downloaded file is of type jar or zip, and downloads it anyway.\n" + "force-latest: If true, does not search for updates compatible with this Minecraft version and simply picks the latest release.\n" + "force-update: If true, downloads the update every time even if its already on the latest version.\n" + diff --git a/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java b/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java index 236a5c79..b38a6ac7 100644 --- a/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java +++ b/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java @@ -223,7 +223,9 @@ public UpdaterConfig() throws IOException, DuplicateKeyException, YamlReaderExce "Updates your mods and the results are sent to AutoPlug-Web. You can configure this in the web-config.", "Note that there is a web-cool-down (that cannot be changed) of a few hours, to prevent spamming of results to AutoPlug-Web."); mods_updater_profile = put(name, "mods-updater", "profile").setDefValues("AUTOMATIC"); - mods_updater_path = put(name, "mods-updater", "path").setDefValues("./mods"); + mods_updater_path = put(name, "mods-updater", "path").setDefValues("./mods").setComments( + "Path to your mods folder.", + "Steam Workshop mods with supported metadata (meta.cpp) can be updated through SteamCMD when server-updater.software is set to a numeric Steam app-id."); mods_updater_version = put(name, "mods-updater", "version").setComments("The Minecraft version to check and download mods for.", "If left empty, taken from server-updater.version above, if also empty, taken from general.yml, if also empty, taken from server jar."); mods_updater_async = put(name, "mods-updater", "async").setDefValues("true").setComments( diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModDownloadTask.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModDownloadTask.java new file mode 100644 index 00000000..6e349cad --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModDownloadTask.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 Osiris-Team. + * All rights reserved. + * + * This software is copyrighted work, licensed under the terms + * of the MIT-License. Consult the "LICENSE" file for details. + */ + +package com.osiris.autoplug.client.tasks.updater.mods; + +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; + +interface ModDownloadTask { + void start(); + + boolean isAlive(); + + String getPlName(); + + SearchResult getSearchResult(); + + boolean isDownloadSuccessful(); + + boolean isInstallSuccessful(); +} diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopMod.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopMod.java new file mode 100644 index 00000000..18402699 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopMod.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2024 Osiris-Team. + * All rights reserved. + * + * This software is copyrighted work, licensed under the terms + * of the MIT-License. Consult the "LICENSE" file for details. + */ + +package com.osiris.autoplug.client.tasks.updater.mods; + +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +/** + * A Steam Workshop mod living in its own directory inside the mods folder. + * It is identified by the {@code publishedid} inside its {@code meta.cpp} file. + * The mod's version is the {@code timestamp} from that file (when present) or + * the {@code time_updated} value returned by the Steam Web API after the first + * update check. Note that the version is intentionally left empty when the + * meta.cpp provides no timestamp, so the first update check always runs. + */ +public class SteamWorkshopMod extends MinecraftMod { + private final File directory; + private String publishedId; + private final String timestamp; + + public SteamWorkshopMod(File directory, String name, String publishedId) { + this(directory, name, publishedId, null); + } + + public SteamWorkshopMod(File directory, String name, String publishedId, String timestamp) { + super(directory.getAbsolutePath(), name, timestamp, "Steam Workshop", null, null, null); + this.directory = directory; + this.publishedId = publishedId; + this.timestamp = timestamp; + } + + public File getDirectory() { + return directory; + } + + public String getPublishedId() { + return publishedId; + } + + public void setPublishedId(String publishedId) { + this.publishedId = publishedId; + } + + public String getTimestamp() { + return timestamp; + } + + @NotNull + public static List findIn(File dir) throws IOException { + if (!dir.exists()) throw new FileNotFoundException("Directory does not exist: " + dir); + List mods = new ArrayList<>(); + File[] files = dir.listFiles(); + if (files == null) return mods; + Arrays.sort(files, Comparator.comparing(File::getName)); + for (File file : files) { + if (!file.isDirectory()) continue; + File metaFile = new File(file, "meta.cpp"); + if (metaFile.exists()) + mods.add(readFromMeta(file, metaFile)); + } + return mods; + } + + static SteamWorkshopMod readFromMeta(File modDir, File metaFile) throws IOException { + String name = modDir.getName(); + String publishedId = null; + String timestamp = null; + for (String line : Files.readAllLines(metaFile.toPath(), StandardCharsets.UTF_8)) { + String trimmedLine = line.trim(); + if (trimmedLine.isEmpty() || trimmedLine.startsWith("//")) continue; + + int equalsIndex = trimmedLine.indexOf('='); + if (equalsIndex < 0) continue; + + String key = trimmedLine.substring(0, equalsIndex).trim(); + String value = trimmedLine.substring(equalsIndex + 1).trim(); + int semicolonIndex = value.indexOf(';'); + if (semicolonIndex < 0) continue; + value = value.substring(0, semicolonIndex).trim(); + if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2) + value = value.substring(1, value.length() - 1); + + if (key.equals("name")) name = value; + if (key.equals("publishedid")) publishedId = value; + if (key.equals("timestamp")) timestamp = value; + } + + if (publishedId == null || !publishedId.matches("\\d+")) + throw new IOException("Failed to read publishedid from " + metaFile); + + return new SteamWorkshopMod(modDir, name, publishedId, timestamp); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopUpdateFinder.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopUpdateFinder.java new file mode 100644 index 00000000..eb373616 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopUpdateFinder.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 Osiris-Team. + * All rights reserved. + * + * This software is copyrighted work, licensed under the terms + * of the MIT-License. Consult the "LICENSE" file for details. + */ + +package com.osiris.autoplug.client.tasks.updater.mods; + +import com.osiris.autoplug.client.configs.UpdaterConfig; +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; +import com.osiris.autoplug.client.utils.SteamCMD; +import org.jetbrains.annotations.NotNull; + +/** + * Finds updates for Steam Workshop mods via the Steam Web API, similar to how + * {@link com.osiris.autoplug.client.tasks.updater.plugins.ResourceFinder} + * finds updates for regular mods. Only reports an update when the Workshop + * item was actually updated after the currently cached version, so mods are + * not re-downloaded on every run. + */ +public class SteamWorkshopUpdateFinder { + private final UpdaterConfig updaterConfig; + private final SteamCMD steamCMD; + + public SteamWorkshopUpdateFinder(UpdaterConfig updaterConfig, SteamCMD steamCMD) { + this.updaterConfig = updaterConfig; + this.steamCMD = steamCMD; + } + + public SearchResult find(@NotNull SteamWorkshopMod mod) { + SearchResult result = new SearchResult(null, SearchResult.Type.UP_TO_DATE, mod.getVersion(), null, "steam-workshop", null, null, false); + result.mod = mod; + String workshopAppId = getWorkshopAppId(); + if (workshopAppId == null) { + result.type = SearchResult.Type.API_ERROR; + result.setException(new Exception("Steam Workshop mod '" + mod.getName() + "' was found, but server-updater.software is not a numeric Steam app-id.")); + return result; + } + + try { + SteamCMD.SteamWorkshopItemDetails details = steamCMD.getWorkshopItemDetails(mod.getPublishedId()); + result.latestVersion = details.getTimeUpdated(); + result.downloadUrl = details.getFileUrl(); + if (hasUpdate(mod, details.getTimeUpdated())) + result.type = SearchResult.Type.UPDATE_AVAILABLE; + } catch (Exception e) { + result.type = SearchResult.Type.API_ERROR; + result.setException(e); + } + return result; + } + + /** + * Returns the Steam app-id configured in server-updater.software, + * or null when it is not a numeric Steam app-id (e.g. "paper"). + */ + public String getWorkshopAppId() { + String workshopAppId = updaterConfig.server_software.asString(); + if (workshopAppId == null || !workshopAppId.matches("\\d+")) + return null; + return workshopAppId; + } + + /** + * Compares the cached version with the time_updated value of the Workshop item. + * A missing cached version means the mod was never update-checked before. + */ + boolean hasUpdate(SteamWorkshopMod mod, String latestTimeUpdated) { + if (latestTimeUpdated == null || latestTimeUpdated.isEmpty()) + return false; + String currentVersion = mod.getVersion(); + if (currentVersion == null || currentVersion.isEmpty()) + return true; + if (latestTimeUpdated.equals(currentVersion)) + return false; + try { + return Long.parseLong(latestTimeUpdated) > Long.parseLong(currentVersion); + } catch (NumberFormatException e) { + // Versions are not numerically comparable (e.g. a meta.cpp FILETIME + // timestamp vs the unix time_updated from Steam). Assume an update. + return true; + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModDownload.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModDownload.java index cb99e5a8..77b3e2bd 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModDownload.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModDownload.java @@ -25,7 +25,7 @@ import java.io.FileOutputStream; -public class TaskModDownload extends BThread { +public class TaskModDownload extends BThread implements ModDownloadTask { private final String plName; private final String plLatestVersion; private final String url; @@ -207,6 +207,10 @@ public File getDownloadDest() { return dest; } + public SearchResult getSearchResult() { + return searchResult; + } + public boolean isDownloadSuccessful() { return isDownloadSuccessful; } diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java index 6d0b078c..a5257f45 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java @@ -15,6 +15,7 @@ import com.osiris.autoplug.client.tasks.updater.plugins.ResourceFinder; import com.osiris.autoplug.client.tasks.updater.search.SearchResult; import com.osiris.autoplug.client.utils.GD; +import com.osiris.autoplug.client.utils.SteamCMD; import com.osiris.autoplug.client.utils.UtilsFile; import com.osiris.autoplug.client.utils.UtilsMinecraft; import com.osiris.betterthread.BThread; @@ -40,7 +41,7 @@ public class TaskModsUpdater extends BThread { private final String manualProfile = "MANUAL"; private final String automaticProfile = "AUTOMATIC"; private final int updatesDownloaded = 0; - private final List downloadTasksList = new ArrayList<>(); + private final List downloadTasksList = new ArrayList<>(); @NotNull private final List includedMods = new ArrayList<>(); @NotNull @@ -81,7 +82,9 @@ public void runAtStart() throws Exception { setStatus("Fetching latest mod data..."); userProfile = updaterConfig.mods_updater_profile.asString(); - this.allMods.addAll(new UtilsMinecraft().getMods(FileManager.convertRelativeToAbsolutePath(updaterConfig.mods_updater_path.asString()))); + File modsDir = FileManager.convertRelativeToAbsolutePath(updaterConfig.mods_updater_path.asString()); + this.allMods.addAll(new UtilsMinecraft().getMods(modsDir)); + this.allMods.addAll(SteamWorkshopMod.findIn(modsDir)); for (MinecraftMod installedMod : allMods) { @@ -96,6 +99,7 @@ public void runAtStart() throws Exception { YamlSection author = modsConfig.put(name, plName, "author").setDefValues(installedMod.getAuthor()); YamlSection modrinthId = modsConfig.put(name, plName, "modrinth-id"); YamlSection curseforgeId = modsConfig.put(name, plName, "curseforge-id"); + YamlSection steamWorkshopId = modsConfig.put(name, plName, "steam-workshop-id"); YamlSection ignoreContentType = modsConfig.put(name, plName, "ignore-content-type").setDefValues("false"); YamlSection forceLatest = modsConfig.put(name, plName, "force-latest").setDefValues("false"); YamlSection forceUpdate = modsConfig.put(name, plName, "force-update").setDefValues("false"); @@ -111,6 +115,8 @@ public void runAtStart() throws Exception { modrinthId.setValues(installedMod.modrinthId); if (installedMod.curseforgeId != null && curseforgeId.asString() == null) curseforgeId.setValues(installedMod.curseforgeId); + if (installedMod instanceof SteamWorkshopMod) + steamWorkshopId.setValues(((SteamWorkshopMod) installedMod).getPublishedId()); // Update the detailed mods in-memory values installedMod.modrinthId = (modrinthId.asString()); @@ -125,6 +131,17 @@ public void runAtStart() throws Exception { installedMod.jenkinsArtifactName = (jenkinsArtifactName.asString()); installedMod.jenkinsBuildId = (jenkinsBuildId.asInt()); installedMod.forceUpdate = forceUpdate.asBoolean(); + if (installedMod instanceof SteamWorkshopMod) { + // Steam Workshop mods don't have the usual jar metadata (author etc.), + // so the checks below don't apply to them. + ((SteamWorkshopMod) installedMod).setPublishedId(steamWorkshopId.asString()); + installedMod.setVersion(version.asString()); + if (exclude.asBoolean()) + excludedMods.add(installedMod); + else + includedMods.add(installedMod); + continue; + } // Check for missing author in internal config if ((installedMod.getVersion() == null) @@ -184,6 +201,7 @@ public void runAtStart() throws Exception { int sizeBukkitMods = 0; int sizeUnknownMods = 0; int sizeCustomMods = 0; + int sizeSteamWorkshopMods = 0; String mcVersion = updaterConfig.mods_updater_version.asString(); @@ -201,7 +219,10 @@ public void runAtStart() throws Exception { includedMods) { try { setStatus("Initialising update check for " + mod.getName() + "..."); - if (mod.customCheckURL != null) { // Custom Check + if (mod instanceof SteamWorkshopMod) { // STEAM WORKSHOP MOD + sizeSteamWorkshopMods++; + activeFutures.add(executorService.submit(() -> createSteamWorkshopUpdateFinder().find((SteamWorkshopMod) mod))); + } else if (mod.customCheckURL != null) { // Custom Check sizeCustomMods++; activeFutures.add(executorService.submit(() -> new ResourceFinder().findByCustomCheckURL(mod))); } else if (mod.jenkinsProjectUrl != null) { // JENKINS MOD @@ -273,11 +294,13 @@ else if (code == SearchResult.Type.RESOURCE_NOT_FOUND) } } + executorService.shutdown(); + // Wait until all download tasks have finished. while (!downloadTasksList.isEmpty()) { Thread.sleep(1000); - TaskModDownload download = null; - for (TaskModDownload task : + ModDownloadTask download = null; + for (ModDownloadTask task : downloadTasksList) { if (!task.isAlive()) { download = task; @@ -305,10 +328,10 @@ else if (code == SearchResult.Type.RESOURCE_NOT_FOUND) matchingResult.type = SearchResult.Type.UPDATE_INSTALLED; YamlSection jenkinsBuildId = modsConfig.get( modsConfigName, download.getPlName(), "alternatives", "jenkins", "build-id"); - jenkinsBuildId.setValues(String.valueOf(download.searchResult.jenkinsId)); + jenkinsBuildId.setValues(String.valueOf(download.getSearchResult().jenkinsId)); YamlSection version = modsConfig.get( modsConfigName, download.getPlName(), "version"); - version.setValues(download.searchResult.getLatestVersion()); + version.setValues(download.getSearchResult().getLatestVersion()); } } @@ -361,8 +384,24 @@ private void doDownloadLogic(@NotNull MinecraftMod mod, SearchResult result) { } if (userProfile.equals(notifyProfile)) { - addInfo("NOTIFY: Mod '" + mod.getName() + "' has an update available (" + mod.getVersion() + " -> " + latest + "). Download url: " + downloadUrl); + if (mod instanceof SteamWorkshopMod) + addInfo("NOTIFY: Steam Workshop mod '" + mod.getName() + "' has an update available (" + mod.getVersion() + " -> " + latest + ")."); + else + addInfo("NOTIFY: Mod '" + mod.getName() + "' has an update available (" + mod.getVersion() + " -> " + latest + "). Download url: " + downloadUrl); } else { + if (mod instanceof SteamWorkshopMod) { + String workshopAppId = createSteamWorkshopUpdateFinder().getWorkshopAppId(); + if (workshopAppId == null) { + getWarnings().add(new BWarning(this, new Exception("Steam Workshop mod '" + mod.getName() + "' was found, but server-updater.software is not a numeric Steam app-id."))); + return; + } + + TaskSteamWorkshopModDownload task = new TaskSteamWorkshopModDownload("SteamWorkshopModDownloader", getManager(), + (SteamWorkshopMod) mod, workshopAppId, userProfile, createSteamCMD(), result); + downloadTasksList.add(task); + task.start(); + return; + } // Make sure that plName and plLatestVersion do not contain any slashes (/ or \) that could break the file name UtilsFile utilsFile = new UtilsFile(); mod.setName(utilsFile.getValidFileName(mod.getName())); @@ -393,6 +432,14 @@ private void doDownloadLogic(@NotNull MinecraftMod mod, SearchResult result) { } + SteamWorkshopUpdateFinder createSteamWorkshopUpdateFinder() { + return new SteamWorkshopUpdateFinder(updaterConfig, createSteamCMD()); + } + + SteamCMD createSteamCMD() { + return new SteamCMD(); + } + /** * Returns a list containing only mods, that contain all the information needed to perform a search.
* That means, that a mod must have its name, its authors name and its version in its internal config file. diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskSteamWorkshopModDownload.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskSteamWorkshopModDownload.java new file mode 100644 index 00000000..32d148be --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskSteamWorkshopModDownload.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2024 Osiris-Team. + * All rights reserved. + * + * This software is copyrighted work, licensed under the terms + * of the MIT-License. Consult the "LICENSE" file for details. + */ + +package com.osiris.autoplug.client.tasks.updater.mods; + +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; +import com.osiris.autoplug.client.utils.SteamCMD; +import com.osiris.betterthread.BThread; +import com.osiris.betterthread.BThreadManager; +import com.osiris.jlib.logger.AL; +import org.apache.commons.io.FileUtils; + +import java.io.File; + +/** + * Downloads a Steam Workshop mod through SteamCMD, following the same pattern + * as {@link TaskModDownload}. With profile MANUAL the downloaded content stays + * in the SteamCMD Workshop cache, with AUTOMATIC it is copied back into the + * existing mod directory. + */ +public class TaskSteamWorkshopModDownload extends BThread implements ModDownloadTask { + private final SteamWorkshopMod mod; + private final String workshopAppId; + private final String profile; + private final SteamCMD steamCMD; + private final SearchResult searchResult; + private boolean isDownloadSuccessful; + private boolean isInstallSuccessful; + + public TaskSteamWorkshopModDownload(String name, BThreadManager manager, SteamWorkshopMod mod, + String workshopAppId, String profile, SteamCMD steamCMD, + SearchResult searchResult) { + super(name, manager); + this.mod = mod; + this.workshopAppId = workshopAppId; + this.profile = profile; + this.steamCMD = steamCMD; + this.searchResult = searchResult; + } + + @Override + public void runAtStart() throws Exception { + super.runAtStart(); + + if (profile.equals("NOTIFY")) { + setStatus("Your profile doesn't allow downloads! Profile: " + profile); + finish(false); + return; + } + + setStatus("Downloading Steam Workshop mod " + mod.getName() + "..."); + boolean isSuccess = steamCMD.installOrUpdateWorkshopItem(workshopAppId, mod.getPublishedId(), + line -> { + AL.debug(this.getClass(), "SteamCMD-Out: " + line); + setStatus(line); + }, errLine -> { + AL.debug(this.getClass(), "SteamCMD-Err-Out: " + errLine); + setStatus(errLine); + addWarning(errLine); + }); + if (!isSuccess) + throw new Exception("Failed to update Steam Workshop mod '" + mod.getName() + "' via SteamCMD."); + + isDownloadSuccessful = true; + File downloadedDir = steamCMD.getWorkshopItemDir(workshopAppId, mod.getPublishedId()); + if (profile.equals("MANUAL")) { + setStatus("Downloaded Steam Workshop mod " + mod.getName() + " to " + downloadedDir.getAbsolutePath()); + return; + } + + setStatus("Installing Steam Workshop mod " + mod.getName() + "..."); + FileUtils.copyDirectory(downloadedDir, mod.getDirectory()); + isInstallSuccessful = true; + setStatus("Installed update for " + mod.getName() + " successfully!"); + } + + public String getPlName() { + return mod.getName(); + } + + public SearchResult getSearchResult() { + return searchResult; + } + + public boolean isDownloadSuccessful() { + return isDownloadSuccessful; + } + + public boolean isInstallSuccessful() { + return isInstallSuccessful; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/utils/SteamCMD.java b/src/main/java/com/osiris/autoplug/client/utils/SteamCMD.java index 9c971e52..b8bb57c6 100644 --- a/src/main/java/com/osiris/autoplug/client/utils/SteamCMD.java +++ b/src/main/java/com/osiris/autoplug/client/utils/SteamCMD.java @@ -8,6 +8,9 @@ package com.osiris.autoplug.client.utils; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import com.osiris.autoplug.client.configs.UpdaterConfig; import com.osiris.autoplug.client.tasks.updater.TaskDownload; import com.osiris.autoplug.client.utils.io.AsyncReader; @@ -20,8 +23,14 @@ import org.rauschig.jarchivelib.ArchiverFactory; import org.rauschig.jarchivelib.CompressionType; +import java.io.BufferedReader; import java.io.File; import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -115,8 +124,7 @@ public boolean installOrUpdateServer(String appId, Consumer onLog, Consu AL.debug(this.getClass(), "Installing app " + appId + "..."); onLog.accept("Installing app " + appId + "..."); - String login = new UpdaterConfig().server_steamcmd_login.asString(); - if (login == null || login.isEmpty()) login = "anonymous"; + String login = getLogin(); File gameInstallDir = new File(dirSteamServersDownloads + "/" + appId); gameInstallDir.mkdirs(); @@ -159,10 +167,151 @@ public boolean installOrUpdateServer(String appId, Consumer onLog, Consu } } + private static final String STEAM_WORKSHOP_DETAILS_URL = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"; + + public boolean installOrUpdateWorkshopItem(String workshopAppId, String workshopItemId, Consumer onLog, Consumer onLogErr) { + try { + if (!installIfNeeded()) return false; + AL.debug(this.getClass(), "Installing workshop item " + workshopItemId + " for app " + workshopAppId + "..."); + onLog.accept("Installing workshop item " + workshopItemId + "..."); + + String command = buildWorkshopItemCommand(getLogin(), workshopAppId, workshopItemId); + AtomicBoolean isFinished = new AtomicBoolean(false); + AtomicBoolean isSuccess = new AtomicBoolean(true); + // Doesn't work when directly executing via ProcessBuilder thats why we execute it from an + // actual terminal + AsyncTerminal terminal = new AsyncTerminal(destDir, line -> { // Without a reader it seems to never finish + onLog.accept(line); + String lowerLine = line.toLowerCase(); + if (lowerLine.startsWith("success.") && lowerLine.contains("item " + workshopItemId.toLowerCase())) + isFinished.set(true); + if (lowerLine.startsWith("error!")) { + isSuccess.set(false); + isFinished.set(true); + } + }, line -> { + onLogErr.accept(line); + }, destExe.getAbsolutePath() + " " + command); + + Thread thread = new Thread(() -> terminal.process.destroy()); + Runtime.getRuntime().addShutdownHook(thread); + while (!isFinished.get() && terminal.process.isAlive()) Thread.sleep(100); + if (terminal.process.isAlive()) terminal.process.destroy(); + Runtime.getRuntime().removeShutdownHook(thread); + return isSuccess.get() && getWorkshopItemDir(workshopAppId, workshopItemId).exists(); + } catch (Exception e) { + AL.warn(e); + return false; + } + } + + /** + * Fetches details for a Steam Workshop item from the public Steam Web API + * (GetPublishedFileDetails). This endpoint only accepts POST form requests + * (GET returns an error), which is why jlib's Json.get(url) cannot be used + * here. Uses plain HttpURLConnection to avoid introducing new dependencies. + */ + public SteamWorkshopItemDetails getWorkshopItemDetails(String workshopItemId) throws IOException { + HttpURLConnection connection = null; + try { + byte[] formData = ("itemcount=1&publishedfileids[0]=" + workshopItemId).getBytes(StandardCharsets.UTF_8); + connection = (HttpURLConnection) new URL(STEAM_WORKSHOP_DETAILS_URL).openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + connection.setRequestProperty("User-Agent", "AutoPlug-Client - https://autoplug.one"); + try (OutputStream out = connection.getOutputStream()) { + out.write(formData); + } + + if (connection.getResponseCode() != 200) + throw new IOException("Steam Workshop details request failed for item " + workshopItemId + " with code " + connection.getResponseCode() + " message: " + connection.getResponseMessage()); + + StringBuilder responseBody = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) responseBody.append(line); + } + + JsonObject root = JsonParser.parseString(responseBody.toString()).getAsJsonObject(); + JsonObject responseObject = root.getAsJsonObject("response"); + JsonArray details = responseObject == null ? null : responseObject.getAsJsonArray("publishedfiledetails"); + if (details == null || details.size() == 0) + throw new IOException("Steam Workshop details request returned no details for item " + workshopItemId); + + JsonObject detail = details.get(0).getAsJsonObject(); + int result = detail.has("result") && !detail.get("result").isJsonNull() ? detail.get("result").getAsInt() : 0; + if (result != 1) + throw new IOException("Steam Workshop details request failed for item " + workshopItemId + " with result " + result); + + String timeUpdated = getString(detail, "time_updated"); + if (timeUpdated == null || timeUpdated.isEmpty()) + throw new IOException("Steam Workshop details for item " + workshopItemId + " did not contain time_updated."); + + return new SteamWorkshopItemDetails( + getString(detail, "publishedfileid"), + getString(detail, "title"), + timeUpdated, + getString(detail, "file_url")); + } finally { + if (connection != null) connection.disconnect(); + } + } + + public File getWorkshopItemDir(String workshopAppId, String workshopItemId) { + return new File(destDir + "/steamapps/workshop/content/" + workshopAppId + "/" + workshopItemId); + } + + static String buildWorkshopItemCommand(String login, String workshopAppId, String workshopItemId) { + return "+login " + login + " +workshop_download_item " + workshopAppId + " " + workshopItemId + " validate +quit"; + } + + private String getLogin() throws NotLoadedException, YamlReaderException, YamlWriterException, IOException, IllegalKeyException, DuplicateKeyException, IllegalListException { + String login = new UpdaterConfig().server_steamcmd_login.asString(); + if (login == null || login.isEmpty()) login = "anonymous"; + return login; + } + + private static String getString(JsonObject object, String key) { + if (object == null || !object.has(key) || object.get(key).isJsonNull()) + return null; + return object.get(key).getAsString(); + } + public String getResolutionForError(String error) { for (Map.Entry entry : errorResolutions.entrySet()) if (error.contains(entry.getKey())) return entry.getValue(); return "Unknown. :("; } + public static class SteamWorkshopItemDetails { + private final String publishedFileId; + private final String title; + private final String timeUpdated; + private final String fileUrl; + + public SteamWorkshopItemDetails(String publishedFileId, String title, String timeUpdated, String fileUrl) { + this.publishedFileId = publishedFileId; + this.title = title; + this.timeUpdated = timeUpdated; + this.fileUrl = fileUrl; + } + + public String getPublishedFileId() { + return publishedFileId; + } + + public String getTitle() { + return title; + } + + public String getTimeUpdated() { + return timeUpdated; + } + + public String getFileUrl() { + return fileUrl; + } + } + } \ No newline at end of file diff --git a/src/test/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopModTest.java b/src/test/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopModTest.java new file mode 100644 index 00000000..b14782d0 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopModTest.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2024 Osiris-Team. + * All rights reserved. + * + * This software is copyrighted work, licensed under the terms + * of the MIT-License. Consult the "LICENSE" file for details. + */ + +package com.osiris.autoplug.client.tasks.updater.mods; + +import com.osiris.autoplug.client.configs.ModsConfig; +import com.osiris.autoplug.client.configs.UpdaterConfig; +import com.osiris.autoplug.client.utils.GD; +import com.osiris.autoplug.client.utils.SteamCMD; +import com.osiris.betterthread.BThreadManager; +import com.osiris.jlib.logger.AL; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SteamWorkshopModTest { + + @TempDir + Path tempDir; + + @Test + void readsPublishedIdAndNameFromMetaCpp() throws Exception { + File modDir = tempDir.resolve("@CF").toFile(); + modDir.mkdirs(); + File metaFile = writeMeta(modDir, + "protocol = 1;", + "publishedid = 1559212036;", + "name = \"CF\";", + "timestamp = 5249804932187309401;"); + + SteamWorkshopMod mod = SteamWorkshopMod.readFromMeta(modDir, metaFile); + + assertEquals(modDir, mod.getDirectory()); + assertEquals("CF", mod.getName()); + assertEquals("1559212036", mod.getPublishedId()); + assertEquals("5249804932187309401", mod.getTimestamp()); + } + + @Test + void findsModsWithMetaCppInDirectSubdirectories() throws Exception { + File firstModDir = tempDir.resolve("@CF").toFile(); + File secondModDir = tempDir.resolve("@CommunityOnlineTools").toFile(); + File ignoredDir = tempDir.resolve("keys").toFile(); + firstModDir.mkdirs(); + secondModDir.mkdirs(); + ignoredDir.mkdirs(); + writeMeta(firstModDir, "publishedid = 1559212036;", "name = \"CF\";"); + writeMeta(secondModDir, "publishedid = 1564026768;", "name = \"Community-Online-Tools\";"); + + List mods = SteamWorkshopMod.findIn(tempDir.toFile()); + + assertEquals(2, mods.size()); + assertEquals("1559212036", mods.get(0).getPublishedId()); + assertEquals("1564026768", mods.get(1).getPublishedId()); + } + + @Test + void taskDoesNotDownloadWorkshopModWhenSteamMetadataIsCurrent() throws Exception { + File oldWorkingDir = GD.WORKING_DIR; + File oldDownloadsDir = GD.DOWNLOADS_DIR; + String oldUserDir = System.getProperty("user.dir"); + useWorkingDir(tempDir); + try { + File modDir = tempDir.resolve("mods/@CF").toFile(); + modDir.mkdirs(); + writeMeta(modDir, "publishedid = 1559212036;", "name = \"CF\";", "timestamp = 100;"); + Files.write(modDir.toPath().resolve("old.txt"), Arrays.asList("old"), StandardCharsets.UTF_8); + + configureUpdater("200"); + FakeSteamCMD steamCMD = new FakeSteamCMD("200", null); + TaskModsUpdater task = createTask(steamCMD); + + task.runAtStart(); + + assertEquals(1, steamCMD.detailsCalls); + assertEquals(0, steamCMD.updateCalls); + assertFalse(Files.exists(modDir.toPath().resolve("updated.txt"))); + assertTrue(task.getWarnings().isEmpty()); + + ModsConfig modsConfig = new ModsConfig(); + modsConfig.load(); + assertEquals("1559212036", modsConfig.get("mods", "CF", "steam-workshop-id").asString()); + assertEquals("200", modsConfig.get("mods", "CF", "version").asString()); + } finally { + System.setProperty("user.dir", oldUserDir); + GD.WORKING_DIR = oldWorkingDir; + GD.DOWNLOADS_DIR = oldDownloadsDir; + } + } + + @Test + void taskDownloadsWorkshopModWhenSteamMetadataIsNewerAndCachesVersion() throws Exception { + File oldWorkingDir = GD.WORKING_DIR; + File oldDownloadsDir = GD.DOWNLOADS_DIR; + String oldUserDir = System.getProperty("user.dir"); + useWorkingDir(tempDir); + try { + File modDir = tempDir.resolve("mods/@CF").toFile(); + modDir.mkdirs(); + writeMeta(modDir, "publishedid = 1559212036;", "name = \"CF\";", "timestamp = 100;"); + Files.write(modDir.toPath().resolve("old.txt"), Arrays.asList("old"), StandardCharsets.UTF_8); + + File downloadedDir = tempDir.resolve("steamcmd/steamapps/workshop/content/221100/1559212036").toFile(); + downloadedDir.mkdirs(); + writeMeta(downloadedDir, "publishedid = 1559212036;", "name = \"CF\";", "timestamp = 200;"); + Files.write(downloadedDir.toPath().resolve("updated.txt"), Arrays.asList("updated"), StandardCharsets.UTF_8); + + configureUpdater("100"); + FakeSteamCMD steamCMD = new FakeSteamCMD("200", downloadedDir); + TaskModsUpdater task = createTask(steamCMD); + + task.runAtStart(); + + assertEquals(1, steamCMD.detailsCalls); + assertEquals(1, steamCMD.updateCalls); + assertEquals("221100", steamCMD.workshopAppId); + assertEquals("1559212036", steamCMD.workshopItemId); + assertEquals("updated", Files.readAllLines(modDir.toPath().resolve("updated.txt"), StandardCharsets.UTF_8).get(0)); + assertTrue(task.getWarnings().isEmpty()); + + ModsConfig modsConfig = new ModsConfig(); + modsConfig.load(); + assertEquals("1559212036", modsConfig.get("mods", "CF", "steam-workshop-id").asString()); + assertEquals("200", modsConfig.get("mods", "CF", "version").asString()); + } finally { + System.setProperty("user.dir", oldUserDir); + GD.WORKING_DIR = oldWorkingDir; + GD.DOWNLOADS_DIR = oldDownloadsDir; + } + } + + private File writeMeta(File modDir, String... lines) throws Exception { + File metaFile = new File(modDir, "meta.cpp"); + Files.write(metaFile.toPath(), Arrays.asList(lines), StandardCharsets.UTF_8); + return metaFile; + } + + private void useWorkingDir(Path dir) throws IOException { + System.setProperty("user.dir", dir.toAbsolutePath().toString()); + GD.VERSION = "AutoPlug-Client Test-Version"; + GD.WORKING_DIR = dir.toFile(); + GD.DOWNLOADS_DIR = dir.resolve("autoplug/downloads").toFile(); + GD.DOWNLOADS_DIR.mkdirs(); + File logFile = dir.resolve("autoplug/logs/latest.log").toFile(); + logFile.getParentFile().mkdirs(); + new AL().start("AL", true, logFile, false, false); + } + + private void configureUpdater(String cachedWorkshopVersion) throws Exception { + UpdaterConfig updaterConfig = new UpdaterConfig(); + updaterConfig.mods_updater.setValues("true"); + updaterConfig.mods_updater_profile.setValues("AUTOMATIC"); + updaterConfig.mods_updater_path.setValues("./mods"); + updaterConfig.mods_updater_version.setValues("1.20.1"); + updaterConfig.mods_updater_async.setValues("false"); + updaterConfig.server_software.setValues("221100"); + updaterConfig.save(); + + ModsConfig modsConfig = new ModsConfig(); + modsConfig.put("mods", "CF", "version").setValues(cachedWorkshopVersion); + modsConfig.put("mods", "CF", "steam-workshop-id").setValues("1559212036"); + modsConfig.save(); + } + + private TaskModsUpdater createTask(FakeSteamCMD steamCMD) { + return new TaskModsUpdater("ModsUpdater", new BThreadManager()) { + @Override + SteamCMD createSteamCMD() { + return steamCMD; + } + }; + } + + private static class FakeSteamCMD extends SteamCMD { + final File workshopItemDir; + final String latestVersion; + int detailsCalls; + int updateCalls; + String workshopAppId; + String workshopItemId; + + FakeSteamCMD(String latestVersion, File workshopItemDir) { + this.latestVersion = latestVersion; + this.workshopItemDir = workshopItemDir; + } + + @Override + public SteamWorkshopItemDetails getWorkshopItemDetails(String workshopItemId) { + detailsCalls++; + return new SteamWorkshopItemDetails(workshopItemId, "CF", latestVersion, null); + } + + @Override + public boolean installOrUpdateWorkshopItem(String workshopAppId, String workshopItemId, Consumer onLog, Consumer onLogErr) { + this.updateCalls++; + this.workshopAppId = workshopAppId; + this.workshopItemId = workshopItemId; + onLog.accept("Success. Downloaded item " + workshopItemId); + return true; + } + + @Override + public File getWorkshopItemDir(String workshopAppId, String workshopItemId) { + return workshopItemDir; + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/utils/SteamCMDTest.java b/src/test/java/com/osiris/autoplug/client/utils/SteamCMDTest.java index 7d39cf1f..5b0743e3 100644 --- a/src/test/java/com/osiris/autoplug/client/utils/SteamCMDTest.java +++ b/src/test/java/com/osiris/autoplug/client/utils/SteamCMDTest.java @@ -13,8 +13,17 @@ import java.io.IOException; +import static org.junit.jupiter.api.Assertions.assertEquals; + class SteamCMDTest { + @Test + void buildsWorkshopItemCommand() { + String command = SteamCMD.buildWorkshopItemCommand("anonymous", "221100", "1559212036"); + + assertEquals("+login anonymous +workshop_download_item 221100 1559212036 validate +quit", command); + } + @Test void installSteamcmd() throws IOException { UtilsTest.init();