From baf7f7143b112e8ce162aa0cbbe752c052499acf Mon Sep 17 00:00:00 2001 From: Juan Patricio Date: Mon, 12 Feb 2024 22:50:26 -0300 Subject: [PATCH] Add the mod manager part of duskers mod manager --- build.gradle | 42 ++- modloader/{Plugin.cs => Patcher.cs} | 0 modloader/modloader.csproj | 2 +- .../dmm/ModManagerApplication.java | 56 +--- .../juanmuscaria/dmm/ModManagerCommand.java | 16 +- ...ctionConfig.java => ReflectiveConfig.java} | 9 +- .../com/juanmuscaria/dmm/data/ModEntry.java | 34 ++ .../com/juanmuscaria/dmm/data/ModList.java | 48 +++ .../juanmuscaria/dmm/data/ModMetadata.java | 10 + .../com/juanmuscaria/dmm/event/FXEvent.java | 28 ++ .../dmm/service/FXMLLoaderFactory.java | 20 ++ .../juanmuscaria/dmm/service/ModManager.java | 307 ++++++++++++++++++ .../dmm/ui/DuskersLauncherController.java | 112 +++++-- .../dmm/ui/InstallerController.java | 8 +- .../com/juanmuscaria/dmm/ui/UILoader.java | 49 +++ .../juanmuscaria/dmm/util/DuskersHelper.java | 58 ++-- src/main/java/module-info.java | 7 + .../native-image/resource-config.json | 14 +- src/main/resources/application.properties | 1 + src/main/resources/ui/duskers_launcher.fxml | 71 +++- 20 files changed, 772 insertions(+), 120 deletions(-) rename modloader/{Plugin.cs => Patcher.cs} (100%) rename src/main/java/com/juanmuscaria/dmm/config/{JavaFXReflectionConfig.java => ReflectiveConfig.java} (86%) create mode 100644 src/main/java/com/juanmuscaria/dmm/data/ModEntry.java create mode 100644 src/main/java/com/juanmuscaria/dmm/data/ModList.java create mode 100644 src/main/java/com/juanmuscaria/dmm/data/ModMetadata.java create mode 100644 src/main/java/com/juanmuscaria/dmm/event/FXEvent.java create mode 100644 src/main/java/com/juanmuscaria/dmm/service/FXMLLoaderFactory.java create mode 100644 src/main/java/com/juanmuscaria/dmm/service/ModManager.java create mode 100644 src/main/java/com/juanmuscaria/dmm/ui/UILoader.java diff --git a/build.gradle b/build.gradle index ade60e1..fcc16d0 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,8 @@ plugins { id 'io.micronaut.application' version '4.2.1' } -version = "0.1" +ext.gitInfoCached = null +version = "0.0.1--${gitInfo('branch')}-${gitInfo('hash')}" group = "com.juanmuscaria" var os = DefaultNativePlatform.currentOperatingSystem @@ -30,6 +31,11 @@ dependencies { implementation("io.github.mkpaz:atlantafx-base:2.0.1") compileOnly("org.projectlombok:lombok") implementation("ch.qos.logback:logback-classic") + implementation('org.apache.tika:tika-core:2.9.1') + implementation('org.apache.commons:commons-collections4:4.4') + implementation('com.fasterxml.jackson.core:jackson-databind:2.16.1') + implementation('com.fasterxml.jackson.core:jackson-annotations:2.16.1') + implementation('com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.16.1') } graalvmNative { @@ -146,11 +152,43 @@ tasks.register("buildModloader", Exec) { processResources { dependsOn("repackBepInEx", "buildModloader") from("$buildDir/repack") + from("$projectDir/modloader/bin/Debug/net35/modloader.dll") + + inputs.property "version", project.version + from(sourceSets.main.resources.srcDirs) { + include 'application.properties' + + expand 'version':project.version + } + + duplicatesStrategy = DuplicatesStrategy.INCLUDE } run { - //jvmArgs += "-agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image" + //jvmArgs += "-agentlib:native-image-agent=config-merge-dir=../src/main/resources/META-INF/native-image" jvmArgs += '-Ddmm.forceLauncher=true' jvmArgs += '-Djansi.mode=force' jvmArgs += '-Dpicocli.ansi=true' + workingDir = buildDir +} + +def gitInfo(String key) { + if (!gitInfoCached) { + if (file('.git').exists()) { + gitInfoCached = [ + hash : ['git', 'log', "--format=%h", '-n', '1'].execute().text.trim(), + fullHash: ['git', 'log', "--format=%H", '-n', '1'].execute().text.trim(), + branch : ['git', 'symbolic-ref', '--short', 'HEAD'].execute().text.trim(), + message : ['git', 'log', "--format=%B", '-n', '1'].execute().text.trim() + ] + } else { + gitInfoCached = [ + hash : 'NOT_A_GIT', + fullHash: 'NOT_A_GIT', + branch : 'NOT_A_GIT', + message : 'NOT_A_GIT' + ] + } + } + return key ? gitInfoCached[key] : gitInfoCached } \ No newline at end of file diff --git a/modloader/Plugin.cs b/modloader/Patcher.cs similarity index 100% rename from modloader/Plugin.cs rename to modloader/Patcher.cs diff --git a/modloader/modloader.csproj b/modloader/modloader.csproj index 85c2ec5..1f909df 100644 --- a/modloader/modloader.csproj +++ b/modloader/modloader.csproj @@ -3,7 +3,7 @@ net35 modloader - My first plugin + Small loader for patched assemblies 1.0.0 true latest diff --git a/src/main/java/com/juanmuscaria/dmm/ModManagerApplication.java b/src/main/java/com/juanmuscaria/dmm/ModManagerApplication.java index 7a4e642..e163f1a 100644 --- a/src/main/java/com/juanmuscaria/dmm/ModManagerApplication.java +++ b/src/main/java/com/juanmuscaria/dmm/ModManagerApplication.java @@ -1,53 +1,27 @@ package com.juanmuscaria.dmm; -import com.juanmuscaria.dmm.util.DialogHelper; -import com.juanmuscaria.dmm.util.DuskersHelper; +import com.juanmuscaria.dmm.event.FXEvent; +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.event.ApplicationEventPublisher; +import jakarta.inject.Singleton; import javafx.application.Application; -import javafx.fxml.FXMLLoader; -import javafx.scene.Scene; import javafx.stage.Stage; -import atlantafx.base.theme.CupertinoLight; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.file.Path; -import java.util.Objects; - +@Singleton public class ModManagerApplication extends Application { - private static final Logger logger = LoggerFactory.getLogger(ModManagerApplication.class); - @Override - public void start(Stage stage) { - setUserAgentStylesheet(new CupertinoLight().getUserAgentStylesheet()); - try { - Scene scene; - if (DuskersHelper.isInstalled(Path.of(".")) || Boolean.getBoolean("dmm.forceLauncher")) { - scene = new Scene(FXMLLoader.load( - Objects.requireNonNull(getClass().getResource("/ui/duskers_launcher.fxml"), - "Unable to load JavaFX resources"))); - stage.setTitle("Duskers Mod Manager"); - } else { - scene = new Scene(FXMLLoader.load( - Objects.requireNonNull(getClass().getResource("/ui/installer.fxml"), - "Unable to load JavaFX resources"))); - stage.setTitle("Duskers Mod Manager Installer"); - } - stage.setScene(scene); - stage.show(); - stage.setMinWidth(stage.getWidth()); - stage.setMinHeight(stage.getHeight()); - } catch (Throwable e) { - logger.error("Unable to start GUI", e); - DialogHelper.reportAndExit(e); - } + static ApplicationContext context; + + public ModManagerApplication() { } - public static void main(String[] args) { - launch(args); + @Override + public void init() { + context.registerSingleton(this); + context.getEventPublisher(FXEvent.FXInit.class).publishEvent(new FXEvent.FXInit(this)); } - public static Path getSelfPath() { - return Path.of(ModManagerApplication.class.getProtectionDomain() - .getCodeSource().getLocation().getPath()).toAbsolutePath(); + @Override + public void start(Stage primaryStage) { + context.getEventPublisher(FXEvent.FXStart.class).publishEvent(new FXEvent.FXStart(this, primaryStage)); } } diff --git a/src/main/java/com/juanmuscaria/dmm/ModManagerCommand.java b/src/main/java/com/juanmuscaria/dmm/ModManagerCommand.java index cc0e9da..6336a57 100644 --- a/src/main/java/com/juanmuscaria/dmm/ModManagerCommand.java +++ b/src/main/java/com/juanmuscaria/dmm/ModManagerCommand.java @@ -1,16 +1,22 @@ package com.juanmuscaria.dmm; import io.micronaut.configuration.picocli.PicocliRunner; -import org.graalvm.nativeimage.IsolateThread; +import io.micronaut.context.ApplicationContext; +import io.micronaut.core.annotation.ReflectiveAccess; +import jakarta.inject.Inject; import picocli.CommandLine.Command; import picocli.CommandLine.Help.Ansi; import picocli.CommandLine.Option; -import org.graalvm.nativeimage.c.function.CEntryPoint; +import java.util.concurrent.CountDownLatch; @Command(name = "dmm", description = "...", mixinStandardHelpOptions = true) public class ModManagerCommand implements Runnable { + public static final CountDownLatch stopLatch = new CountDownLatch(1); + @Inject + @ReflectiveAccess + protected ApplicationContext context; @Option(names = {"--no-gui", "-G"}, description = "Enables CLI mode", defaultValue = "false") boolean noGui; @@ -18,15 +24,11 @@ public static void main(String[] args) { PicocliRunner.run(ModManagerCommand.class, args); } - @CEntryPoint(name = "dmm_main") - public static void dmm_main(IsolateThread thread) { - ModManagerApplication.launch(ModManagerApplication.class); - } - public void run() { if (noGui) { System.out.println(Ansi.AUTO.string("@|red CLI mode is not implemented yet!|@")); } else { + ModManagerApplication.context = context; ModManagerApplication.launch(ModManagerApplication.class); } } diff --git a/src/main/java/com/juanmuscaria/dmm/config/JavaFXReflectionConfig.java b/src/main/java/com/juanmuscaria/dmm/config/ReflectiveConfig.java similarity index 86% rename from src/main/java/com/juanmuscaria/dmm/config/JavaFXReflectionConfig.java rename to src/main/java/com/juanmuscaria/dmm/config/ReflectiveConfig.java index 73f0521..4785e77 100644 --- a/src/main/java/com/juanmuscaria/dmm/config/JavaFXReflectionConfig.java +++ b/src/main/java/com/juanmuscaria/dmm/config/ReflectiveConfig.java @@ -5,6 +5,7 @@ import static io.micronaut.core.annotation.TypeHint.AccessType.*; @ReflectionConfig(type = org.fusesource.jansi.AnsiConsole.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) +@ReflectionConfig(type = java.util.concurrent.ConcurrentSkipListSet.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) @ReflectionConfig(type = javafx.geometry.Insets.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) @ReflectionConfig(type = javafx.scene.control.Button.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) @ReflectionConfig(type = javafx.scene.control.ComboBox.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) @@ -52,6 +53,12 @@ @ReflectionConfig(type = javafx.scene.control.TextArea.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) @ReflectionConfig(type = javafx.scene.control.TextInputControl.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) @ReflectionConfig(type = javafx.scene.layout.AnchorPane.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) +@ReflectionConfig(type = javafx.scene.control.ListView.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) +@ReflectionConfig(type = javafx.scene.control.ScrollPane.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) +@ReflectionConfig(type = javafx.scene.control.ScrollPane.ScrollBarPolicy.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) +@ReflectionConfig(type = javafx.scene.layout.ColumnConstraints.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) +@ReflectionConfig(type = javafx.scene.layout.GridPane.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) +@ReflectionConfig(type = javafx.scene.layout.RowConstraints.class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) //@ReflectionConfig(type = .class, accessType = {ALL_PUBLIC, ALL_DECLARED_CONSTRUCTORS, ALL_DECLARED_FIELDS, ALL_DECLARED_METHODS}) -public class JavaFXReflectionConfig { +public class ReflectiveConfig { } diff --git a/src/main/java/com/juanmuscaria/dmm/data/ModEntry.java b/src/main/java/com/juanmuscaria/dmm/data/ModEntry.java new file mode 100644 index 0000000..c85e428 --- /dev/null +++ b/src/main/java/com/juanmuscaria/dmm/data/ModEntry.java @@ -0,0 +1,34 @@ +package com.juanmuscaria.dmm.data; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.micronaut.core.annotation.ReflectiveAccess; +import lombok.*; +import lombok.EqualsAndHashCode.Exclude; +import lombok.extern.jackson.Jacksonized; + +import java.nio.file.Path; + +@JsonSerialize +@AllArgsConstructor +@Builder +@Jacksonized +@Getter +@Setter +@EqualsAndHashCode +@ToString +@ReflectiveAccess +public class ModEntry implements Comparable { + @ReflectiveAccess + private final String modPath; + @Exclude + @ReflectiveAccess + private final ModMetadata metadata; + @Exclude // We don't want to compare state + @ReflectiveAccess + private boolean enabled; + + @Override + public int compareTo(ModEntry other) { + return this.getModPath().compareTo(other.getModPath()); + } +} diff --git a/src/main/java/com/juanmuscaria/dmm/data/ModList.java b/src/main/java/com/juanmuscaria/dmm/data/ModList.java new file mode 100644 index 0000000..9b4a449 --- /dev/null +++ b/src/main/java/com/juanmuscaria/dmm/data/ModList.java @@ -0,0 +1,48 @@ +package com.juanmuscaria.dmm.data; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.micronaut.core.annotation.ReflectiveAccess; +import javafx.collections.FXCollections; +import javafx.collections.ObservableSet; +import lombok.Getter; +import lombok.ToString; + +import java.util.concurrent.ConcurrentSkipListSet; + +@JsonSerialize +@ToString +@ReflectiveAccess +public class ModList { + /** + * This will be the actual backing set that will be written to disk, it must be both thread safe and sorted + */ + @JsonProperty("mods") + @ReflectiveAccess + private ConcurrentSkipListSet backend; + + /** + * Observable wrapper to be used with javafx, changes must be done on the platform thread + */ + @JsonIgnore + @Getter + private ObservableSet mods; + + public ModList() { + this(new ConcurrentSkipListSet<>()); + } + + public ModList(ConcurrentSkipListSet mods) { + this.backend = mods; + this.mods = FXCollections.observableSet(backend); + } + + /** + * Will be called by jackson + */ + protected void setBackend(ConcurrentSkipListSet backend) { + this.backend = backend; + this.mods = FXCollections.observableSet(backend); + } +} diff --git a/src/main/java/com/juanmuscaria/dmm/data/ModMetadata.java b/src/main/java/com/juanmuscaria/dmm/data/ModMetadata.java new file mode 100644 index 0000000..b30c803 --- /dev/null +++ b/src/main/java/com/juanmuscaria/dmm/data/ModMetadata.java @@ -0,0 +1,10 @@ +package com.juanmuscaria.dmm.data; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.micronaut.core.annotation.ReflectiveAccess; + +@JsonSerialize +@ReflectiveAccess +public record ModMetadata(String name, String id, String version) { +} + diff --git a/src/main/java/com/juanmuscaria/dmm/event/FXEvent.java b/src/main/java/com/juanmuscaria/dmm/event/FXEvent.java new file mode 100644 index 0000000..aa8d312 --- /dev/null +++ b/src/main/java/com/juanmuscaria/dmm/event/FXEvent.java @@ -0,0 +1,28 @@ +package com.juanmuscaria.dmm.event; + +import io.micronaut.context.event.ApplicationEvent; +import io.micronaut.core.annotation.NonNull; +import javafx.stage.Stage; +import lombok.Getter; + +public class FXEvent extends ApplicationEvent { + public FXEvent(Object source) { + super(source); + } + + public static class FXInit extends FXEvent { + public FXInit(Object source) { + super(source); + } + } + + @Getter + public static class FXStart extends @NonNull FXEvent { + private final Stage primaryStage; + + public FXStart(Object source, Stage primaryStage) { + super(source); + this.primaryStage = primaryStage; + } + } +} diff --git a/src/main/java/com/juanmuscaria/dmm/service/FXMLLoaderFactory.java b/src/main/java/com/juanmuscaria/dmm/service/FXMLLoaderFactory.java new file mode 100644 index 0000000..9605760 --- /dev/null +++ b/src/main/java/com/juanmuscaria/dmm/service/FXMLLoaderFactory.java @@ -0,0 +1,20 @@ +package com.juanmuscaria.dmm.service; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Prototype; +import jakarta.inject.Inject; +import javafx.fxml.FXMLLoader; + +@Factory +class FXMLLoaderFactory { + @Inject + protected ApplicationContext context; + + @Prototype + public FXMLLoader getLoader() { + var loader = new FXMLLoader(); + loader.setControllerFactory(context::getBean); + return loader; + } +} diff --git a/src/main/java/com/juanmuscaria/dmm/service/ModManager.java b/src/main/java/com/juanmuscaria/dmm/service/ModManager.java new file mode 100644 index 0000000..f3d5791 --- /dev/null +++ b/src/main/java/com/juanmuscaria/dmm/service/ModManager.java @@ -0,0 +1,307 @@ +package com.juanmuscaria.dmm.service; + +import com.fasterxml.jackson.databind.DatabindException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.dataformat.toml.TomlMapper; +import com.juanmuscaria.dmm.data.ModEntry; +import com.juanmuscaria.dmm.data.ModList; +import com.juanmuscaria.dmm.data.ModMetadata; +import com.juanmuscaria.dmm.util.DuskersHelper; +import io.micronaut.core.annotation.ReflectiveAccess; +import io.micronaut.scheduling.annotation.Scheduled; +import jakarta.inject.Singleton; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.SetChangeListener; +import lombok.Getter; +import org.apache.commons.collections4.bidimap.DualHashBidiMap; +import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.SystemUtils; +import org.apache.tika.Tika; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Base64; +import java.util.HashSet; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; +import java.util.zip.ZipFile; + +@Singleton +@ReflectiveAccess +public class ModManager { + public static final String MOD_LIST_FILE = "mods.toml"; + private static final Logger logger = LoggerFactory.getLogger(ModManager.class); + private final ObjectMapper MAPPER = new TomlMapper(); + private final ObjectWriter WRITER = MAPPER.writerWithDefaultPrettyPrinter(); + private final Tika TIKA = new Tika(); + private final @Getter Path duskersDir; + private final @Getter Path managerDir; + private final @Getter Path modsDir; + private final @Getter Path patchersDir; + private final @Getter Path pluginsDir; + private final @Getter Path patchedAssembliesDir; + private final AtomicBoolean requiresSaving = new AtomicBoolean(); + private final ObjectProperty modList = new SimpleObjectProperty<>(new ModList()); + private WatchService modChangeService; + + public ModManager() { + duskersDir = DuskersHelper.getSelfPath().getParent().toAbsolutePath(); + managerDir = duskersDir.resolve("BepInEx/ModManager"); + modsDir = managerDir.resolve("mods"); + patchersDir = duskersDir.resolve("BepInEx/patchers"); + pluginsDir = duskersDir.resolve("BepInEx/plugins"); + patchedAssembliesDir = patchersDir.resolve(DuskersHelper.ASSEMBLY_PATCHER_PATH).resolve("replace"); + logger.info("Mod Manager files will reside in {}", managerDir); + modList.addListener((observable, oldValue, newValue) -> + newValue.getMods().addListener((SetChangeListener) change -> { + logger.debug("Change to mod list detected! Added {}, Removed {}.", change.getElementAdded(), change.getElementRemoved()); + requiresSaving.set(true); + })); + } + + public void loadOrCreateFiles() throws IOException { + if (!Files.isDirectory(managerDir)) { + Files.createDirectories(managerDir); + } + + if (!Files.isDirectory(modsDir)) { + Files.createDirectories(modsDir); + } + + if (!Files.isDirectory(patchersDir)) { + Files.createDirectories(patchersDir); + } + + if (!Files.isDirectory(pluginsDir)) { + Files.createDirectories(pluginsDir); + } + + if (!Files.isDirectory(patchedAssembliesDir)) { + Files.createDirectories(patchedAssembliesDir); + } + + var modListPath = managerDir.resolve(MOD_LIST_FILE); + if (Files.exists(modListPath)) { + try { + modList.set(MAPPER.readValue(Files.newInputStream(modListPath), ModList.class)); + } catch (Throwable e) { + logger.warn("Unable to load mod list. Enabled mod status will be lost", e); + } + } + + updateModList(); + this.requiresSaving.set(true); + + try { + modChangeService = FileSystems.getDefault().newWatchService(); + modsDir.register(modChangeService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY); + } catch (Exception e) { + logger.warn("File watching is unsupported in your system! Manual changes to mods won't be detected without restart", e); + } + } + + public void updateModList() { + var foundMods = new HashSet(); + + try (DirectoryStream stream = Files.newDirectoryStream(modsDir)) { + for (Path entry : stream) { + try { + var mod = readMod(entry); + if (mod != null) { + foundMods.add(new ModEntry(entry.toAbsolutePath().toString(), mod, false)); + } + } catch (IOException e) { + logger.warn("Unable to parse mod {}", entry, e); + } + } + } catch (IOException e) { + logger.warn("Unable to update the mod list", e); + } + + modList.get().getMods().retainAll(foundMods); + foundMods.removeAll(modList.get().getMods()); + + for (ModEntry foundMod : foundMods) { + this.getModlist().getMods().add(foundMod); + } + } + + public ModMetadata readMod(Path modPath) throws IOException { + if (Files.isDirectory(modPath)) { + var metadata = modPath.resolve("mod.toml"); + if (Files.isRegularFile(metadata)) { + return MAPPER.readValue(Files.newInputStream(metadata), ModMetadata.class); + } + } else if (Files.isRegularFile(modPath)) { + var mimeType = TIKA.detect(modPath); + if (mimeType.equals("application/zip")) { + try (var zipFile = new ZipFile(modPath.toFile())) { + var entry = zipFile.getEntry("mod.toml"); + if (entry != null) { + return MAPPER.readValue(zipFile.getInputStream(entry), ModMetadata.class); + } + } + } + } else { + logger.warn("Tried to read {} but it does not exists!?", modPath); + } + return null; + } + + @Scheduled(fixedRate = "1s") + void pollFilesystemEvents() { + if (modChangeService != null) { + try { + var key = modChangeService.poll(0, TimeUnit.SECONDS); + if (key != null) { + for (WatchEvent event : key.pollEvents()) { + var path = modsDir.resolve(((Path) event.context())).toAbsolutePath(); + if (event.kind() == StandardWatchEventKinds.ENTRY_CREATE || event.kind() == StandardWatchEventKinds.ENTRY_MODIFY) { + try { + var metadata = readMod(path); + if (metadata != null) { + Platform.runLater(() -> this.getModlist().getMods().add(new ModEntry(path.toString(), metadata, false))); + } + } catch (Exception e) { + logger.debug("Ignoring mods folder change, possible invalid mod file", e); + } + } else if (event.kind() == StandardWatchEventKinds.ENTRY_DELETE) { + Platform.runLater(() -> this.getModlist().getMods().removeIf(modEntry -> modEntry.getModPath().equals(path.toString()))); + } + } + key.reset(); // Continue listening to new events + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // Pass up + } + } + } + + @Scheduled(fixedRate = "1s") + void saveModList() { + if (this.requiresSaving.getAndSet(false)) { + try { + Files.writeString(managerDir.resolve(MOD_LIST_FILE), + WRITER.writeValueAsString(modList.get()), StandardCharsets.UTF_8); + } catch (IOException e) { + logger.error("Unable to save mod list to disk!", e); + } + } + } + + public ModList getModlist() { + return this.modList.get(); + } + + public BooleanProperty makeModEnabledProperty(ModEntry entry) { + var property = new SimpleBooleanProperty(entry.isEnabled()); + property.addListener((observable, oldValue, newValue) -> { + entry.setEnabled(newValue); + requiresSaving.set(true); + }); + return property; + } + + public void updateInstalledMods() throws IOException { + var encoder = Base64.getUrlEncoder(); + var decoder = Base64.getUrlDecoder(); + var enabledMods = new DualHashBidiMap(); + + // Make sure the patcher is installed + DuskersHelper.unpackAssemblyPatcher(patchersDir); + FileUtils.cleanDirectory(patchedAssembliesDir.toFile()); + + // Compute enabled mods + for (ModEntry mod : this.getModlist().getMods()) { + if (mod.isEnabled()) { + var identifier = encoder.encodeToString(("dmmManagedMod:"+mod.getMetadata().id()).getBytes(StandardCharsets.UTF_8)); + if (enabledMods.containsValue(identifier)) { + logger.warn("{} is incompatible with {} and will be ignored!", mod, enabledMods.getKey(identifier)); + } else { + enabledMods.put(mod, identifier); + } + } + } + + // First, we need to remove any non-enabled mods + try (var modPaths = Stream.concat(Files.walk(patchersDir, 0), Files.walk(pluginsDir, 0))) { + modPaths.parallel().filter(path -> { + try { + var identifier = new String(decoder.decode(path.getFileName().toString()), StandardCharsets.UTF_8); + return identifier.startsWith("dmmManagedMod") && !enabledMods.containsValue(identifier); + } catch (IllegalArgumentException ignored) { + return false; + } + }).forEach(path -> { + try { + FileUtils.deleteDirectory(path.toFile()); + } catch (IOException e) { + logger.error("Unable to delete mod {}", path, e); + } + }); + } + + // Now we unpack all enabled mods + var osSpecificReplacePath = "replace/" + (SystemUtils.IS_OS_WINDOWS ? "windows" : "linux"); + for (Map.Entry modEntry : enabledMods.entrySet()) { + var modPath = Path.of(modEntry.getKey().getModPath()); + if (Files.isDirectory(modPath)) { + var modReplaceDir = modPath.resolve(osSpecificReplacePath); + var modPluginDir = modPath.resolve("plugin"); + var modPatcherDir = modPath.resolve("patcher"); + if (Files.isDirectory(modReplaceDir)) { + FileUtils.copyDirectory(modReplaceDir.toFile(), patchedAssembliesDir.toFile()); + } + if (Files.isDirectory(modPluginDir)) { + FileUtils.copyDirectory(modPluginDir.toFile(), pluginsDir.resolve(modEntry.getValue()).toFile()); + } + if (Files.isDirectory(modPatcherDir)) { + FileUtils.copyDirectory(modPatcherDir.toFile(), patchersDir.resolve(modEntry.getValue()).toFile()); + } + } else if (Files.isRegularFile(modPath)) { + var mimeType = TIKA.detect(modPath); + if (mimeType.equals("application/zip")) { + //TODO: Prevent transversal path? + try (var zipFile = new ZipFile(modPath.toFile())) { + zipFile.entries().asIterator().forEachRemaining(entry -> { + try { + if (!entry.isDirectory()) { + if (entry.getName().startsWith(osSpecificReplacePath)) { + var targetPath = patchedAssembliesDir.resolve(entry.getName().substring(osSpecificReplacePath.length() + 1)); + Files.createDirectories(targetPath.getParent()); + Files.copy(zipFile.getInputStream(entry), targetPath, StandardCopyOption.REPLACE_EXISTING); + } else if (entry.getName().startsWith("plugin")) { + var targetPath = pluginsDir.resolve(modEntry.getValue()).resolve(entry.getName().substring(7)); + Files.createDirectories(targetPath.getParent()); + Files.copy(zipFile.getInputStream(entry), targetPath, StandardCopyOption.REPLACE_EXISTING); + } else if (entry.getName().startsWith("patcher")) { + var targetPath = patchersDir.resolve(modEntry.getValue()).resolve(entry.getName().substring(8)); + Files.createDirectories(targetPath.getParent()); + Files.copy(zipFile.getInputStream(entry), targetPath, StandardCopyOption.REPLACE_EXISTING); + } else { + logger.debug("Ignored entry {}", entry.getName()); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + throw e.getCause(); // Ugh java lambdas + } + } + } + } + } +} diff --git a/src/main/java/com/juanmuscaria/dmm/ui/DuskersLauncherController.java b/src/main/java/com/juanmuscaria/dmm/ui/DuskersLauncherController.java index 45c131f..7904227 100644 --- a/src/main/java/com/juanmuscaria/dmm/ui/DuskersLauncherController.java +++ b/src/main/java/com/juanmuscaria/dmm/ui/DuskersLauncherController.java @@ -4,37 +4,50 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.AppenderBase; import ch.qos.logback.core.Context; +import com.juanmuscaria.dmm.data.ModEntry; +import com.juanmuscaria.dmm.service.ModManager; import com.juanmuscaria.dmm.util.DialogHelper; import com.juanmuscaria.dmm.util.DuskersHelper; +import io.micronaut.context.annotation.Value; import io.micronaut.core.annotation.ReflectiveAccess; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import javafx.application.Application; import javafx.application.Platform; +import javafx.collections.SetChangeListener; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; import javafx.scene.control.TextArea; +import javafx.scene.control.cell.CheckBoxListCell; +import javafx.scene.input.DragEvent; +import javafx.util.StringConverter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.nio.file.Path; +@Singleton @ReflectiveAccess public class DuskersLauncherController { private static final Logger logger = LoggerFactory.getLogger(DuskersLauncherController.class); + public ListView modListView; + public TextArea logs; + public Label versionLabel; + public Button launch; + public Button launchUnmodded; private Process duskers; - - @FXML - @ReflectiveAccess - private TextArea logs; - - @FXML - @ReflectiveAccess - private Button launch; - - @FXML - @ReflectiveAccess - private Button launchUnmodded; + @Inject + public ModManager modManager; + @Inject + public Application application; + @Value("${dmm.version}") + public String version; @FXML @ReflectiveAccess @@ -44,13 +57,46 @@ void initialize() { var appender = new LogbackListAppender(rootLogger.getLoggerContext()); appender.start(); rootLogger.addAppender(appender); + + versionLabel.setText(version); + + try { + modManager.loadOrCreateFiles(); + } catch (Throwable e) { + DialogHelper.reportAndExit(e); + } + + modListView.setCellFactory(CheckBoxListCell.forListView(item -> modManager.makeModEnabledProperty(item), new StringConverter() { + @Override + public String toString(ModEntry object) { + return "%s %s [%s, %s]".formatted(object.getMetadata().name(), + object.getMetadata().version(), object.getMetadata().id(), Path.of(object.getModPath()).getFileName()); + } + + @Override + public ModEntry fromString(String string) { + return null; + } + })); + + modListView.getItems().addAll(modManager.getModlist().getMods()); + modManager.getModlist().getMods().addListener((SetChangeListener) change -> { + modListView.getItems().clear(); + modListView.getItems().addAll(change.getSet()); + }); } @FXML @ReflectiveAccess void launch(ActionEvent event) { event.consume(); - logs.clear(); + logger.info("Preparing mods"); + try { + modManager.updateInstalledMods(); + } catch (Exception e) { + DialogHelper.reportAndWait(e, "Mod preparation failed", "Seems like something went wrong when preparing your mods, possibly a bug on the mod manager!"); + return; + } logger.info("Launching Duskers with mods"); launch(true); } @@ -59,7 +105,6 @@ void launch(ActionEvent event) { @ReflectiveAccess void launchUnmodded(ActionEvent event) { event.consume(); - logs.clear(); logger.info("Launching Duskers without mods"); launch(false); } @@ -91,21 +136,17 @@ public void run() { } } - private class LogbackListAppender extends AppenderBase { - private final PatternLayout layout = new PatternLayout(); + public void dragDroppedOnMods(DragEvent dragEvent) { + logger.warn("STUB File was dropped but no file handling is implemented"); + } - LogbackListAppender(Context ctx) { - layout.setContext(ctx); - layout.setPattern("%-5level %logger{36} - %msg%n"); - layout.start(); - this.setContext(ctx); - } + public void installModButtonClicked(ActionEvent event) { + DialogHelper.infoAndWait("This button should actually open the system file picker", "Bother me if I forgot to implement"); + } - @Override - protected void append(ILoggingEvent event) { - var logMessage = layout.doLayout(event); - Platform.runLater(() -> logs.appendText(logMessage)); - } + public void openModFolderButtonClicked(ActionEvent event) { + event.consume(); + application.getHostServices().showDocument(modManager.getModsDir().toUri().toString()); } private static class LogPump extends Thread { @@ -133,4 +174,21 @@ public void run() { } } } + + private class LogbackListAppender extends AppenderBase { + private final PatternLayout layout = new PatternLayout(); + + LogbackListAppender(Context ctx) { + layout.setContext(ctx); + layout.setPattern("%-5level %logger{36} - %msg%n"); + layout.start(); + this.setContext(ctx); + } + + @Override + protected void append(ILoggingEvent event) { + var logMessage = layout.doLayout(event); + Platform.runLater(() -> logs.appendText(logMessage)); + } + } } \ No newline at end of file diff --git a/src/main/java/com/juanmuscaria/dmm/ui/InstallerController.java b/src/main/java/com/juanmuscaria/dmm/ui/InstallerController.java index 87b1344..7aef913 100644 --- a/src/main/java/com/juanmuscaria/dmm/ui/InstallerController.java +++ b/src/main/java/com/juanmuscaria/dmm/ui/InstallerController.java @@ -3,6 +3,7 @@ import com.juanmuscaria.dmm.util.DialogHelper; import com.juanmuscaria.dmm.util.DuskersHelper; import io.micronaut.core.annotation.ReflectiveAccess; +import jakarta.inject.Singleton; import javafx.beans.value.ObservableValue; import javafx.event.ActionEvent; import javafx.fxml.FXML; @@ -15,6 +16,7 @@ import java.nio.file.Files; import java.nio.file.Path; +@Singleton @ReflectiveAccess public class InstallerController { @FXML @@ -51,16 +53,16 @@ void install(ActionEvent event) { DuskersHelper.installModManager(gamePath); installInfo.setText("Mod loader installed"); DialogHelper.infoAndWait("Mod loader installed", - "You may close the installer and launch the game normally"); + "You may close the installer and launch the game normally"); } else { throw new DialogHelper.ReportedException("Insufficient Permission", "The installer could not write to the game folder " + - "due to missing permission, try running the installer as administrator instead."); + "due to missing permission, try running the installer as administrator instead."); } } catch (DialogHelper.ReportedException e) { DialogHelper.reportAndWait(e); } catch (Exception e) { DialogHelper.reportAndWait(e, "Error during installation", - "An unknown error occurred during the installation"); + "An unknown error occurred during the installation"); } } diff --git a/src/main/java/com/juanmuscaria/dmm/ui/UILoader.java b/src/main/java/com/juanmuscaria/dmm/ui/UILoader.java new file mode 100644 index 0000000..9f7aa5b --- /dev/null +++ b/src/main/java/com/juanmuscaria/dmm/ui/UILoader.java @@ -0,0 +1,49 @@ +package com.juanmuscaria.dmm.ui; + +import atlantafx.base.theme.CupertinoLight; +import com.juanmuscaria.dmm.event.FXEvent; +import com.juanmuscaria.dmm.util.DialogHelper; +import com.juanmuscaria.dmm.util.DuskersHelper; +import io.micronaut.runtime.event.annotation.EventListener; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.Objects; + +@Singleton +public class UILoader { + private static final Logger logger = LoggerFactory.getLogger(UILoader.class); + @Inject + FXMLLoader loader; + + @EventListener + void onAppStart(FXEvent.FXStart event) { + Application.setUserAgentStylesheet(new CupertinoLight().getUserAgentStylesheet()); + var stage = event.getPrimaryStage(); + try { + if (DuskersHelper.isInstalled(Path.of(".")) || Boolean.getBoolean("dmm.forceLauncher")) { + loader.setLocation(Objects.requireNonNull(getClass().getResource("/ui/duskers_launcher.fxml"), + "Unable to load JavaFX resources")); + stage.setTitle("Duskers Mod Manager"); + } else { + loader.setLocation( + Objects.requireNonNull(getClass().getResource("/ui/installer.fxml"), + "Unable to load JavaFX resources")); + stage.setTitle("Duskers Mod Manager Installer"); + } + stage.setScene(new Scene(loader.load())); + stage.show(); + stage.setMinWidth(stage.getWidth()); + stage.setMinHeight(stage.getHeight()); + } catch (Throwable e) { + logger.error("Unable to start GUI", e); + DialogHelper.reportAndExit(e); + } + } +} diff --git a/src/main/java/com/juanmuscaria/dmm/util/DuskersHelper.java b/src/main/java/com/juanmuscaria/dmm/util/DuskersHelper.java index adbd222..f9bd6d9 100644 --- a/src/main/java/com/juanmuscaria/dmm/util/DuskersHelper.java +++ b/src/main/java/com/juanmuscaria/dmm/util/DuskersHelper.java @@ -22,6 +22,7 @@ public class DuskersHelper { private static final String LINUX_STEAM_PATH = ".local/share/Steam/steamapps/common/Duskers"; private static final String LINUX_FLATPAK = ".var/app/com.valvesoftware.Steam/" + LINUX_STEAM_PATH; private static final String WINDOWS_STEAM_PATH = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Duskers"; + public static final String ASSEMBLY_PATCHER_PATH = "DuskersModManagerPatcher"; public static List getPossibleDuskersFolders() { var paths = new ArrayList(2); @@ -106,7 +107,7 @@ public static void installModManager(Path basePath) throws IOException, DialogHe var newDataFolder = DuskersHelper.getNewDataFolder(basePath); Files.move(duskersBinary, newDuskersBinary); - Files.copy(ModManagerApplication.getSelfPath(), duskersBinary); + Files.copy(getSelfPath(), duskersBinary); Files.createSymbolicLink(newDataFolder, dataFolder); unpackLoader(basePath); @@ -116,17 +117,25 @@ private static void unpackLoader(Path basePath) throws IOException, DialogHelper try (var loader = DuskersHelper.class.getResourceAsStream((SystemUtils.IS_OS_WINDOWS ? "/win.zip" : "/unix.zip"))) { if (loader == null) { throw new DialogHelper.ReportedException("Essential Files Missing!", "If you see this message it means something went " + - "wrong when building the installer and it's missing important files required to install the ModManager."); + "wrong when building the installer and it's missing important files required to install the ModManager."); } unzip(loader, basePath); } -// try (var modloader = DuskersHelper.class.getResourceAsStream("/modloader.dll")) { -// if (modloader == null) { -// throw new ReportedException("Essential Files Missing!", "If you see this message it means something went " + -// "wrong when building the installer and it's missing important files required to install the modloader."); -// } -// Files.copy(modloader, basePath.resolve("modloader.dll")); -// } + } + + public static void unpackAssemblyPatcher(Path basePath) throws IOException { + var modloaderPath = basePath.resolve(ASSEMBLY_PATCHER_PATH); + var modLoaderFile = modloaderPath.resolve("modloader.dll"); + if (!Files.isRegularFile(modLoaderFile)) { + try (var modLoader = DuskersHelper.class.getResourceAsStream("/modloader.dll")) { + if (modLoader == null) { + throw new IOException("If you see this message it means something went " + + "wrong when building the installer and it's missing important built-in files required to load mods"); + } + Files.createDirectories(modloaderPath); + Files.copy(modLoader, modLoaderFile); + } + } } private static void unzip(InputStream source, Path target) throws IOException { @@ -147,22 +156,21 @@ private static void unzip(InputStream source, Path target) throws IOException { } } - public static ProcessBuilder buildDuskersLaunchProcess(boolean modded) throws DialogHelper.ReportedException { var pb = new ProcessBuilder(); var cmd = new ArrayList(); var env = pb.environment(); - var local = ModManagerApplication.getSelfPath().toAbsolutePath().getParent(); - logger.info("Duskers path:" + local); + var local = getSelfPath().toAbsolutePath().getParent(); + logger.info("Duskers path: " + local); if (SystemUtils.IS_OS_WINDOWS) { cmd.addAll(Arrays.asList("cmd", "/c", - getNewDuskersBinary(local).toAbsolutePath().toString())); + getNewDuskersBinary(local).toAbsolutePath().toString())); writeWinConfig(modded); } else if (SystemUtils.IS_OS_LINUX) { cmd.add(getNewDuskersBinary(local).toAbsolutePath().toString()); env.put("LD_LIBRARY_PATH", local + "/doorstop_libs:" + env.get("LD_LIBRARY_PATH")); - env.put("LD_PRELOAD","libdoorstop_x64.so:" + env.get("LD_PRELOAD")); + env.put("LD_PRELOAD", "libdoorstop_x64.so:" + env.get("LD_PRELOAD")); env.put("DOORSTOP_ENABLE", modded ? "TRUE" : "FALSE"); env.put("DOORSTOP_INVOKE_DLL_PATH", local + "/BepInEx/core/BepInEx.Preloader.dll"); env.put("DOORSTOP_CORLIB_OVERRIDE_PATH", ""); @@ -176,16 +184,22 @@ public static ProcessBuilder buildDuskersLaunchProcess(boolean modded) throws Di private static void writeWinConfig(boolean modded) throws DialogHelper.ReportedException { try { Files.writeString(Path.of(".", "doorstop_config.ini"), String.format(""" - [UnityDoorstop] - enabled=%b - targetAssembly=BepInEx\\core\\BepInEx.Preloader.dll - redirectOutputLog=false - ignoreDisableSwitch=false - dllSearchPathOverride= - """, modded), StandardCharsets.UTF_8); + [UnityDoorstop] + enabled=%b + targetAssembly=BepInEx\\core\\BepInEx.Preloader.dll + redirectOutputLog=false + ignoreDisableSwitch=false + dllSearchPathOverride= + """, modded), StandardCharsets.UTF_8); } catch (IOException e) { throw new DialogHelper.ReportedException("Unable to write to doorstop_config.ini", "An IO error occurred writing to" + - " doorstop_config.ini, ensure your user has permission to write to the game directory.", e); + " doorstop_config.ini, ensure your user has permission to write to the game directory.", e); } } + + + public static Path getSelfPath() { + return Path.of(ModManagerApplication.class.getProtectionDomain() + .getCodeSource().getLocation().getPath()).toAbsolutePath(); + } } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index cee600f..54fa5ec 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -6,6 +6,7 @@ requires io.micronaut.inject; requires info.picocli; requires io.micronaut.context; + requires jakarta.inject; requires io.micronaut.picocli.micronaut_picocli; requires java.logging; requires org.apache.commons.lang3; @@ -18,4 +19,10 @@ requires static lombok; requires ch.qos.logback.classic; requires ch.qos.logback.core; + requires com.fasterxml.jackson.annotation; + requires com.fasterxml.jackson.core; + requires com.fasterxml.jackson.databind; + requires com.fasterxml.jackson.dataformat.toml; + requires org.apache.tika.core; + requires org.apache.commons.collections4; } \ No newline at end of file diff --git a/src/main/resources/META-INF/native-image/resource-config.json b/src/main/resources/META-INF/native-image/resource-config.json index 6995ee6..f9553c6 100644 --- a/src/main/resources/META-INF/native-image/resource-config.json +++ b/src/main/resources/META-INF/native-image/resource-config.json @@ -1,8 +1,12 @@ { "resources":{ - "includes":[ - { - "pattern":"\\Qatlantafx/base/theme/cupertino-light.css\\E" - } - ]} + "includes":[{ + "pattern":"\\Qatlantafx/base/theme/cupertino-light.bss\\E" + }, { + "pattern": "\\Qatlantafx/base/theme/cupertino-light.css\\E" + }, { + "pattern":"\\Qorg/apache/tika/mime/custom-mimetypes.xml\\E" + }, { + "pattern":"\\Qorg/apache/tika/mime/tika-mimetypes.xml\\E" + }]} } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 14fbf87..c0c09ab 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,2 +1,3 @@ #Sat Dec 23 17:46:32 UTC 2023 micronaut.application.name=DuskersModManager +dmm.version=${version} diff --git a/src/main/resources/ui/duskers_launcher.fxml b/src/main/resources/ui/duskers_launcher.fxml index 0b713f5..bdc67ee 100644 --- a/src/main/resources/ui/duskers_launcher.fxml +++ b/src/main/resources/ui/duskers_launcher.fxml @@ -2,27 +2,51 @@ + + + + + + + - - - - + - + - + + + + + + + + + + + + + + + + + + +
@@ -44,7 +68,32 @@ - + +
+ + + + + +
+ + + + + +