diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/DayZWorkshopModUpdater.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/DayZWorkshopModUpdater.java new file mode 100644 index 00000000..7ff691e8 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/DayZWorkshopModUpdater.java @@ -0,0 +1,194 @@ +/* + * 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 java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DayZWorkshopModUpdater { + public static final String DAYZ_SERVER_APP_ID = "223350"; + public static final String DAYZ_WORKSHOP_APP_ID = "221100"; + private static final Pattern PUBLISHED_ID_PATTERN = Pattern.compile("(?m)^\\s*publishedid\\s*=\\s*\"?([0-9]+)\"?\\s*;?\\s*$"); + private static final Pattern NAME_PATTERN = Pattern.compile("(?m)^\\s*name\\s*=\\s*\"?([^\";]+)\"?\\s*;?\\s*$"); + + private final File serverDir; + private final File workshopDownloadDir; + + public DayZWorkshopModUpdater(File serverDir, File workshopDownloadDir) { + this.serverDir = serverDir; + this.workshopDownloadDir = workshopDownloadDir; + } + + public File getWorkshopDownloadDir() { + return workshopDownloadDir; + } + + public List findInstalledMods() throws IOException { + List mods = new ArrayList<>(); + File[] children = serverDir.listFiles(File::isDirectory); + if (children == null) return mods; + + for (File dir : children) { + File metaCpp = findMetaCpp(dir); + if (metaCpp == null) continue; + DayZWorkshopMod mod = parseMetaCpp(metaCpp, dir); + mods.add(mod); + } + return mods; + } + + public DayZWorkshopMod parseMetaCpp(File metaCpp, File modDir) throws IOException { + String content = new String(Files.readAllBytes(metaCpp.toPath()), StandardCharsets.UTF_8); + String publishedId = matchRequired(PUBLISHED_ID_PATTERN, content, "publishedid", metaCpp); + String name = matchOptional(NAME_PATTERN, content); + if (name == null || name.trim().isEmpty()) name = modDir.getName(); + return new DayZWorkshopMod(name.trim(), publishedId, modDir); + } + + public List getWorkshopIds(List mods) { + List ids = new ArrayList<>(); + for (DayZWorkshopMod mod : mods) { + ids.add(mod.publishedId); + } + return ids; + } + + public int installDownloadedMods(List mods) throws IOException { + int installed = 0; + for (DayZWorkshopMod mod : mods) { + File downloadedModDir = getDownloadedWorkshopContentDir(mod); + if (!downloadedModDir.isDirectory()) { + throw new IOException("Downloaded DayZ Workshop mod was not found at " + downloadedModDir.getAbsolutePath()); + } + replaceDirectory(downloadedModDir, mod.directory); + copyKeysIfPresent(mod.directory); + installed++; + } + return installed; + } + + public File getDownloadedWorkshopContentDir(DayZWorkshopMod mod) { + return new File(workshopDownloadDir + "/steamapps/workshop/content/" + DAYZ_WORKSHOP_APP_ID + "/" + mod.publishedId); + } + + private File findMetaCpp(File dir) { + File[] files = dir.listFiles(file -> file.isFile() && file.getName().equalsIgnoreCase("meta.cpp")); + if (files == null || files.length == 0) return null; + return files[0]; + } + + private void replaceDirectory(File sourceDir, File targetDir) throws IOException { + File backupDir = new File(targetDir.getParentFile(), targetDir.getName() + ".autoplug-backup"); + deleteDirectory(backupDir); + + boolean hadTarget = targetDir.exists(); + if (hadTarget) moveDirectory(targetDir, backupDir); + + try { + copyDirectory(sourceDir, targetDir); + if (hadTarget) deleteDirectory(backupDir); + } catch (IOException e) { + deleteDirectory(targetDir); + if (backupDir.exists()) moveDirectory(backupDir, targetDir); + throw e; + } + } + + private void copyKeysIfPresent(File modDir) throws IOException { + File modKeysDir = new File(modDir, "keys"); + File[] keys = modKeysDir.listFiles(file -> file.isFile() + && file.getName().toLowerCase(Locale.ROOT).endsWith(".bikey")); + if (keys == null || keys.length == 0) return; + + File serverKeysDir = new File(serverDir, "keys"); + serverKeysDir.mkdirs(); + for (File key : keys) { + Files.copy(key.toPath(), new File(serverKeysDir, key.getName()).toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } + + private void copyDirectory(File sourceDir, File targetDir) throws IOException { + Path sourcePath = sourceDir.toPath(); + Path targetPath = targetDir.toPath(); + Files.walkFileTree(sourcePath, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + Files.createDirectories(targetPath.resolve(sourcePath.relativize(dir))); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.copy(file, targetPath.resolve(sourcePath.relativize(file)), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); + return FileVisitResult.CONTINUE; + } + }); + } + + private void deleteDirectory(File dir) throws IOException { + if (!dir.exists()) return; + Files.walkFileTree(dir.toPath(), new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + if (exc != null) throw exc; + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } + + private void moveDirectory(File sourceDir, File targetDir) throws IOException { + Files.move(sourceDir.toPath(), targetDir.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + + private String matchRequired(Pattern pattern, String content, String fieldName, File file) throws IOException { + String value = matchOptional(pattern, content); + if (value == null) { + throw new IOException("Missing " + fieldName + " in " + file.getAbsolutePath()); + } + return value; + } + + private String matchOptional(Pattern pattern, String content) { + Matcher matcher = pattern.matcher(content); + if (!matcher.find()) return null; + return matcher.group(1); + } + + public static class DayZWorkshopMod { + public final String name; + public final String publishedId; + public final File directory; + + public DayZWorkshopMod(String name, String publishedId, File directory) { + this.name = name; + this.publishedId = publishedId; + this.directory = directory; + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/server/TaskServerUpdater.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/server/TaskServerUpdater.java index d561c634..5a7a951d 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/server/TaskServerUpdater.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/server/TaskServerUpdater.java @@ -12,6 +12,8 @@ import com.osiris.autoplug.client.configs.GeneralConfig; import com.osiris.autoplug.client.configs.UpdaterConfig; import com.osiris.autoplug.client.managers.FileManager; +import com.osiris.autoplug.client.tasks.updater.mods.DayZWorkshopModUpdater; +import com.osiris.autoplug.client.tasks.updater.mods.DayZWorkshopModUpdater.DayZWorkshopMod; import com.osiris.autoplug.client.tasks.updater.TaskDownload; import com.osiris.autoplug.client.tasks.updater.search.GithubSearch; import com.osiris.autoplug.client.tasks.updater.search.JenkinsSearch; @@ -32,6 +34,7 @@ import java.io.File; import java.io.IOException; +import java.util.List; import static com.osiris.jprocesses2.util.OS.isWindows; @@ -59,23 +62,19 @@ public void runAtStart() throws Exception { profile = updaterConfig.server_updater_profile.asString(); serverSoftware = updaterConfig.server_software.asString(); - serverVersion = updaterConfig.server_updater_version.asString(); - if (serverVersion == null) serverVersion = new GeneralConfig().server_version.asString(); - if (serverVersion == null) serverVersion = new UtilsMinecraft().getInstalledVersion(); - if (serverVersion == null) throw new NullPointerException(GD.errorMsgFailedToGetMCVersion()); + boolean isSteamAppId = isSteamAppId(updaterConfig.server_software.asString()); + boolean isAlternativeUpdater = updaterConfig.server_github_repo_name.asString() != null + || updaterConfig.server_jenkins_project_url.asString() != null; + if (!isSteamAppId && !isAlternativeUpdater) { + serverVersion = updaterConfig.server_updater_version.asString(); + if (serverVersion == null) serverVersion = new GeneralConfig().server_version.asString(); + if (serverVersion == null) serverVersion = new UtilsMinecraft().getInstalledVersion(); + if (serverVersion == null) throw new NullPointerException(GD.errorMsgFailedToGetMCVersion()); + } setStatus("Searching for updates..."); - boolean isSteamAppId = false; - try { - Integer.parseInt(updaterConfig.server_software.asString()); - // Steam ids are only numbers, thus - // if this fails we know it's not a steam id - isSteamAppId = true; - } catch (Exception e) { - } - - if (updaterConfig.server_github_repo_name.asString() != null || updaterConfig.server_jenkins_project_url.asString() != null) { + if (isAlternativeUpdater) { doAlternativeUpdatingLogic(); } else { if (isSteamAppId) @@ -272,7 +271,53 @@ private void doSteamUpdaterLogic() throws Exception { } } - setStatus("Installed updated if needed (SteamCMD)."); + if (DayZWorkshopModUpdater.DAYZ_SERVER_APP_ID.equals(updaterConfig.server_software.asString())) { + doDayZWorkshopModUpdate(steamCMD); + } else { + setStatus("Installed updated if needed (SteamCMD)."); + } setSuccess(true); } + + private boolean isSteamAppId(String software) { + try { + Integer.parseInt(software); + return true; + } catch (Exception e) { + return false; + } + } + + private void doDayZWorkshopModUpdate(SteamCMD steamCMD) throws Exception { + DayZWorkshopModUpdater dayZModUpdater = new DayZWorkshopModUpdater(GD.WORKING_DIR, steamCMD.dirDayZWorkshopDownloads); + List installedMods = dayZModUpdater.findInstalledMods(); + if (installedMods.isEmpty()) { + setStatus("No DayZ Workshop mods with meta.cpp files were found."); + addInfo("No DayZ Workshop mods with meta.cpp files were found."); + return; + } + + setStatus("Updating " + installedMods.size() + " DayZ Workshop mod(s)..."); + boolean isSuccess = steamCMD.installOrUpdateWorkshopItems( + DayZWorkshopModUpdater.DAYZ_WORKSHOP_APP_ID, + dayZModUpdater.getWorkshopIds(installedMods), + dayZModUpdater.getWorkshopDownloadDir(), + line -> { + AL.debug(this.getClass(), "SteamCMD-Workshop-Out: " + line); + setStatus(line); + }, + errLine -> { + AL.debug(this.getClass(), "SteamCMD-Workshop-Err-Out: " + errLine); + setStatus(errLine); + addWarning(errLine); + }); + if (!isSuccess) throw new Exception("Failed to update DayZ Workshop mods with SteamCMD."); + + if (profile.equals("AUTOMATIC")) { + int installed = dayZModUpdater.installDownloadedMods(installedMods); + setStatus("Installed " + installed + " DayZ Workshop mod update(s)."); + } else { + setStatus("Downloaded DayZ Workshop mod update(s) to " + dayZModUpdater.getWorkshopDownloadDir().getAbsolutePath() + "."); + } + } } 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..7f722d7c 100644 --- a/src/main/java/com/osiris/autoplug/client/utils/SteamCMD.java +++ b/src/main/java/com/osiris/autoplug/client/utils/SteamCMD.java @@ -47,6 +47,7 @@ public class SteamCMD { public File destDir = new File(GD.WORKING_DIR + "/autoplug/system/steamcmd"); public File destExe = new File(destDir + "/" + steamcmdExecutable); public File dirSteamServersDownloads = new File(GD.DOWNLOADS_DIR + "/steam-servers"); + public File dirDayZWorkshopDownloads = new File(GD.DOWNLOADS_DIR + "/dayz-workshop"); public File destArchive = new File(destDir + "/" + steamcmdArchive); public boolean isInstalled() { @@ -140,7 +141,7 @@ public boolean installOrUpdateServer(String appId, Consumer onLog, Consu onLogErr.accept(line); logErrLines.add(line); }, - destExe.getAbsolutePath() + " " + steamcmdCommand + quoteForTerminal(destExe.getAbsolutePath()) + " " + steamcmdCommand .replace("{LOGIN}", login) .replace("{DESTINATION}", gameInstallDir.getAbsolutePath()) .replace("{APP}", appId)); @@ -159,10 +160,75 @@ public boolean installOrUpdateServer(String appId, Consumer onLog, Consu } } + public boolean installOrUpdateWorkshopItems(String workshopAppId, List itemIds, File installDir, + Consumer onLog, Consumer onLogErr) { + try { + if (itemIds == null || itemIds.isEmpty()) return true; + if (!installIfNeeded()) return false; + AL.debug(this.getClass(), "Installing workshop items for app " + workshopAppId + "..."); + onLog.accept("Installing " + itemIds.size() + " Steam Workshop item(s)..."); + + String login = new UpdaterConfig().server_steamcmd_login.asString(); + if (login == null || login.isEmpty()) login = "anonymous"; + if (installDir == null) installDir = dirDayZWorkshopDownloads; + + installDir.mkdirs(); + StringBuilder steamcmdWorkshopCommand = new StringBuilder(quoteForTerminal(destExe.getAbsolutePath())) + .append(" +login ").append(login) + .append(" +force_install_dir \"").append(installDir.getAbsolutePath()).append("\""); + for (String itemId : itemIds) { + steamcmdWorkshopCommand.append(" +workshop_download_item ") + .append(workshopAppId) + .append(" ") + .append(itemId); + } + steamcmdWorkshopCommand.append(" +quit"); + + String doneToken = "AUTOPLUG_STEAMCMD_WORKSHOP_DONE_" + System.nanoTime(); + AtomicBoolean isFinished = new AtomicBoolean(false); + AtomicBoolean isSuccess = new AtomicBoolean(true); + AsyncTerminal terminal = new AsyncTerminal(destDir, line -> { + if (line.contains(doneToken)) { + isFinished.set(true); + return; + } + onLog.accept(line); + String lowerLine = line.toLowerCase(); + if (lowerLine.startsWith("error!") || lowerLine.contains("workshop item download failed")) { + isSuccess.set(false); + isFinished.set(true); + } + }, line -> { + onLogErr.accept(line); + String lowerLine = line.toLowerCase(); + if (lowerLine.startsWith("error!") || lowerLine.contains("workshop item download failed")) { + isSuccess.set(false); + isFinished.set(true); + } + }, steamcmdWorkshopCommand.toString(), "echo " + doneToken); + + Thread thread = new Thread(() -> { + terminal.process.destroy(); + }); + Runtime.getRuntime().addShutdownHook(thread); + while (!isFinished.get()) Thread.sleep(100); + terminal.process.destroy(); + Runtime.getRuntime().removeShutdownHook(thread); + return isSuccess.get(); + } catch (Exception e) { + AL.warn(e); + return false; + } + } + + private String quoteForTerminal(String value) { + return "\"" + value + "\""; + } + 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 +} diff --git a/src/test/java/com/osiris/autoplug/client/tasks/updater/mods/DayZWorkshopModUpdaterTest.java b/src/test/java/com/osiris/autoplug/client/tasks/updater/mods/DayZWorkshopModUpdaterTest.java new file mode 100644 index 00000000..b803da45 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/tasks/updater/mods/DayZWorkshopModUpdaterTest.java @@ -0,0 +1,64 @@ +package com.osiris.autoplug.client.tasks.updater.mods; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +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 DayZWorkshopModUpdaterTest { + @TempDir + Path tempDir; + + @Test + void findsDayZModsAndParsesPublishedIdWithSemicolon() throws Exception { + Path serverDir = tempDir.resolve("server"); + Path modDir = serverDir.resolve("@CF"); + Files.createDirectories(modDir); + Files.write(modDir.resolve("meta.cpp"), ( + "protocol = 1;\n" + + "publishedid = 1559212036;\n" + + "name = \"CF\";\n" + + "timestamp = 5249804932187309401;\n").getBytes(StandardCharsets.UTF_8)); + + DayZWorkshopModUpdater updater = new DayZWorkshopModUpdater( + serverDir.toFile(), + tempDir.resolve("downloads").toFile()); + + List mods = updater.findInstalledMods(); + + assertEquals(1, mods.size()); + assertEquals("1559212036", mods.get(0).publishedId); + assertEquals("CF", mods.get(0).name); + } + + @Test + void installsDownloadedWorkshopModAndCopiesKeys() throws Exception { + Path serverDir = tempDir.resolve("server"); + Path modDir = serverDir.resolve("@CF"); + Files.createDirectories(modDir); + Files.write(modDir.resolve("meta.cpp"), "publishedid = 1559212036;\nname = \"CF\";\n".getBytes(StandardCharsets.UTF_8)); + Files.write(modDir.resolve("stale.txt"), "old".getBytes(StandardCharsets.UTF_8)); + + Path downloadsDir = tempDir.resolve("downloads"); + Path downloadedModDir = downloadsDir.resolve("steamapps/workshop/content/221100/1559212036"); + Files.createDirectories(downloadedModDir.resolve("keys")); + Files.write(downloadedModDir.resolve("meta.cpp"), "publishedid = 1559212036;\nname = \"CF\";\n".getBytes(StandardCharsets.UTF_8)); + Files.write(downloadedModDir.resolve("updated.txt"), "new".getBytes(StandardCharsets.UTF_8)); + Files.write(downloadedModDir.resolve("keys/cf.bikey"), "key".getBytes(StandardCharsets.UTF_8)); + + DayZWorkshopModUpdater updater = new DayZWorkshopModUpdater(serverDir.toFile(), downloadsDir.toFile()); + List mods = updater.findInstalledMods(); + + assertEquals(1, updater.installDownloadedMods(mods)); + assertTrue(Files.exists(modDir.resolve("updated.txt"))); + assertFalse(Files.exists(modDir.resolve("stale.txt"))); + assertTrue(Files.exists(serverDir.resolve("keys/cf.bikey"))); + } +}