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 45437a41..6c815a82 100644 --- a/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java +++ b/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java @@ -230,6 +230,9 @@ public UpdaterConfig() throws IOException, DuplicateKeyException, YamlReaderExce mods_update_check_name_for_mod_loader = put(name, "mods-updater", "check-name-for-mod-loader").setDefValues("false").setComments( "Only relevant for determining if a curseforge mod release is forge or fabric.", "If enabled additionally checks the mod name to see if it contains fabric or forge."); + mods_updater_path.setComments( + "Path to your mods folder.", + "Steam Workshop mods with supported metadata can be updated through SteamCMD when server-updater.software is set to a numeric Steam app-id."); save(); unlockFile(); 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..39e90ba2 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/SteamWorkshopMod.java @@ -0,0 +1,102 @@ +/* + * 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; + +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 != null ? timestamp : publishedId, "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; + if (getVersion() == null) + setVersion(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/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..d540fefa 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,15 @@ public void runAtStart() throws Exception { installedMod.jenkinsArtifactName = (jenkinsArtifactName.asString()); installedMod.jenkinsBuildId = (jenkinsBuildId.asInt()); installedMod.forceUpdate = forceUpdate.asBoolean(); + if (installedMod instanceof SteamWorkshopMod) { + ((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,10 +199,10 @@ public void runAtStart() throws Exception { int sizeBukkitMods = 0; int sizeUnknownMods = 0; int sizeCustomMods = 0; - + int sizeSteamWorkshopMods = 0; String mcVersion = updaterConfig.mods_updater_version.asString(); - if (mcVersion == null) updaterConfig.server_updater_version.asString(); + if (mcVersion == null) mcVersion = updaterConfig.server_updater_version.asString(); if (mcVersion == null) mcVersion = Server.getMCVersion(); ExecutorService executorService; @@ -201,7 +216,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) { + sizeSteamWorkshopMods++; + activeFutures.add(executorService.submit(() -> findSteamWorkshopUpdate((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 @@ -272,12 +290,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 +324,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()); } } @@ -337,6 +356,63 @@ else if (code == SearchResult.Type.RESOURCE_NOT_FOUND) } + private SearchResult findSteamWorkshopUpdate(@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 = createSteamCMD().getWorkshopItemDetails(mod.getPublishedId()); + result.latestVersion = details.getTimeUpdated(); + result.downloadUrl = details.getFileUrl(); + if (hasSteamWorkshopUpdate(mod, details.getTimeUpdated())) + result.type = SearchResult.Type.UPDATE_AVAILABLE; + } catch (Exception e) { + result.type = SearchResult.Type.API_ERROR; + result.setException(e); + } + return result; + } + + private String getWorkshopAppId() { + String workshopAppId = updaterConfig.server_software.asString(); + if (workshopAppId == null || !workshopAppId.matches("\\d+")) + return null; + return workshopAppId; + } + + private boolean hasSteamWorkshopUpdate(SteamWorkshopMod mod, String latestVersion) { + if (latestVersion == null || latestVersion.isEmpty()) + return false; + String currentVersion = mod.getVersion(); + if (currentVersion == null || currentVersion.isEmpty()) + return true; + if (latestVersion.equals(currentVersion)) + return false; + if (currentVersion.equals(mod.getPublishedId())) + return true; + if (isSteamUnixTimestamp(latestVersion) && !isSteamUnixTimestamp(currentVersion)) + return true; + try { + return Long.parseLong(latestVersion) > Long.parseLong(currentVersion); + } catch (NumberFormatException e) { + return true; + } + } + + private boolean isSteamUnixTimestamp(String version) { + return version != null && version.matches("\\d{1,10}"); + } + + SteamCMD createSteamCMD() { + return new SteamCMD(); + } + private void doDownloadLogic(@NotNull MinecraftMod mod, SearchResult result) { SearchResult.Type code = result.type; String type = result.getDownloadType(); // The file type to download (Note: When 'external' is returned nothing will be downloaded. Working on a fix for this!) @@ -361,8 +437,25 @@ 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 = 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())); 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..0a89b311 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskSteamWorkshopModDownload.java @@ -0,0 +1,91 @@ +/* + * 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; + +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..e4cb0b2c 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; @@ -15,6 +18,11 @@ import com.osiris.betterthread.BThreadManager; import com.osiris.dyml.exceptions.*; import com.osiris.jlib.logger.AL; +import okhttp3.FormBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; import org.rauschig.jarchivelib.ArchiveFormat; import org.rauschig.jarchivelib.Archiver; import org.rauschig.jarchivelib.ArchiverFactory; @@ -35,6 +43,8 @@ @SuppressWarnings({"WeakerAccess", "unused"}) public class SteamCMD { + private static final String STEAM_WORKSHOP_DETAILS_URL = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"; + private static final String STEAMCMD_WORKSHOP_COMMAND = "+login {LOGIN} +workshop_download_item {WORKSHOP_APP} {WORKSHOP_ITEM} validate +quit"; private final String steamcmdArchive = "steamcmd" + (isWindows ? ".zip" : isMac ? "_osx.tar.gz" : "_linux.tar.gz"); private final String steamcmdExtension = isWindows ? ".exe" : ".sh"; private final String steamcmdExecutable = "steamcmd" + steamcmdExtension; @@ -115,8 +125,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 +168,147 @@ public boolean installOrUpdateServer(String appId, Consumer onLog, Consu } } + 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); + List logLines = new ArrayList<>(); + List logErrLines = new ArrayList<>(); + AtomicBoolean isFinished = new AtomicBoolean(false); + AtomicBoolean isSuccess = new AtomicBoolean(true); + AsyncTerminal terminal = new AsyncTerminal(destDir, line -> { + onLog.accept(line); + logLines.add(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); + logErrLines.add(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; + } + } + + public SteamWorkshopItemDetails getWorkshopItemDetails(String workshopItemId) throws IOException { + Request request = new Request.Builder() + .url(STEAM_WORKSHOP_DETAILS_URL) + .post(new FormBody.Builder() + .add("itemcount", "1") + .add("publishedfileids[0]", workshopItemId) + .build()) + .header("User-Agent", "AutoPlug-Client - https://autoplug.one") + .build(); + + Response response = new OkHttpClient().newCall(request).execute(); + ResponseBody body = null; + try { + if (response.code() != 200) + throw new IOException("Steam Workshop details request failed for item " + workshopItemId + " with code " + response.code() + " message: " + response.message()); + + body = response.body(); + if (body == null) + throw new IOException("Steam Workshop details request returned no body for item " + workshopItemId); + + JsonObject root = JsonParser.parseString(body.string()).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").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 (body != null) body.close(); + response.close(); + } + } + + 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 STEAMCMD_WORKSHOP_COMMAND + .replace("{LOGIN}", login) + .replace("{WORKSHOP_APP}", workshopAppId) + .replace("{WORKSHOP_ITEM}", workshopItemId); + } + + 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. :("; } -} \ No newline at end of file + 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; + } + } + +} 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..95c1ca44 100644 --- a/src/test/java/com/osiris/autoplug/client/utils/SteamCMDTest.java +++ b/src/test/java/com/osiris/autoplug/client/utils/SteamCMDTest.java @@ -13,11 +13,20 @@ 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(); new SteamCMD().installIfNeeded(); } -} \ No newline at end of file +}