From 4cb3441af8ed8a8f354cd2f0ef9864ae9a920d53 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Mon, 17 Jan 2022 20:03:01 -0600 Subject: [PATCH 01/25] Add some structure for mod management --- ThunderstoreCLI/Game/GameDefinition.cs | 36 ++++++++++++++++++++++++++ ThunderstoreCLI/Game/InstallHelpers.cs | 6 +++++ ThunderstoreCLI/Game/ModManager.cs | 14 ++++++++++ ThunderstoreCLI/Game/ModProfile.cs | 26 +++++++++++++++++++ ThunderstoreCLI/Options.cs | 5 ++++ 5 files changed, 87 insertions(+) create mode 100644 ThunderstoreCLI/Game/GameDefinition.cs create mode 100644 ThunderstoreCLI/Game/InstallHelpers.cs create mode 100644 ThunderstoreCLI/Game/ModManager.cs create mode 100644 ThunderstoreCLI/Game/ModProfile.cs diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs new file mode 100644 index 0000000..9f8ab45 --- /dev/null +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using ThunderstoreCLI.Models; + +namespace ThunderstoreCLI.Game; + +public class GameDefinition : BaseJson +{ + private const string FILE_NAME = "GameDefintions.json"; + public string Identifier { get; } + public string Name { get; } + public string ModManager { get; } + public string InstallDirectory { get; } + public List Profiles { get; private set; } = new(); + public ModProfile GlobalProfile { get; } + + internal GameDefinition(string id, string name, string modManager, string tcliDirectory) + { + Identifier = id; + Name = name; + ModManager = modManager; + GlobalProfile = new ModProfile(this, true, "Global", tcliDirectory); + // TODO: actually find install dir instead of manually setting the path in json + // yes im lazy + } + + public static List GetGameDefinitions(string tcliDirectory) + { + return DeserializeList(File.ReadAllText(Path.Combine(tcliDirectory, FILE_NAME))) ?? new(); + } + + public static void SetGameDefinitions(string tcliDirectory, List list) + { + File.WriteAllText(Path.Combine(tcliDirectory, FILE_NAME), list.SerializeList(BaseJson.IndentedSettings)); + } +} diff --git a/ThunderstoreCLI/Game/InstallHelpers.cs b/ThunderstoreCLI/Game/InstallHelpers.cs new file mode 100644 index 0000000..6945e1e --- /dev/null +++ b/ThunderstoreCLI/Game/InstallHelpers.cs @@ -0,0 +1,6 @@ +namespace ThunderstoreCLI.Game; + +public static class InstallHelpers +{ + +} diff --git a/ThunderstoreCLI/Game/ModManager.cs b/ThunderstoreCLI/Game/ModManager.cs new file mode 100644 index 0000000..d024c23 --- /dev/null +++ b/ThunderstoreCLI/Game/ModManager.cs @@ -0,0 +1,14 @@ +using System.IO.Compression; + +namespace ThunderstoreCLI.Game; + +public interface ModManager +{ + public static abstract string Identifier { get; } + public bool SupportsProfiles(GameDefinition gameDef); + public void InstallLoader(GameDefinition gameDef, ModProfile profile, PackageManifestV1 manifest, ZipArchive loaderArchive); + public void UninstallLoader(GameDefinition gameDef, ModProfile profile); + public void InstallMod(GameDefinition gameDef, ModProfile profile, PackageManifestV1 manifest, ZipArchive modArchive); + public void UninstallMod(GameDefinition gameDef, ModProfile profile, PackageManifestV1 manifest); + public int RunGame(GameDefinition gameDef, ModProfile profile); +} diff --git a/ThunderstoreCLI/Game/ModProfile.cs b/ThunderstoreCLI/Game/ModProfile.cs new file mode 100644 index 0000000..afb877c --- /dev/null +++ b/ThunderstoreCLI/Game/ModProfile.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; +using ThunderstoreCLI.Models; + +namespace ThunderstoreCLI.Game; + +public class ModProfile : BaseJson +{ + public bool IsGlobal { get; } + public string Name { get; } + public string ProfileDirectory { get; } + public List InstalledMods { get; set; } = new(); + + internal ModProfile(GameDefinition gameDef, bool global, string name, string tcliDirectory) + { + IsGlobal = global; + Name = name; + + var directory = Path.Combine(tcliDirectory, "Profiles", gameDef.Identifier, name); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + ProfileDirectory = directory; + } +} diff --git a/ThunderstoreCLI/Options.cs b/ThunderstoreCLI/Options.cs index d457814..f1fff12 100644 --- a/ThunderstoreCLI/Options.cs +++ b/ThunderstoreCLI/Options.cs @@ -77,6 +77,11 @@ public override bool Validate() return false; } + if (!Directory.Exists(TcliDirectory)) + { + Directory.CreateDirectory(TcliDirectory!); + } + return true; } } From 3b2fcc1f58b397490d94d8d0ea29033a9e8a2e43 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Mon, 31 Jan 2022 22:13:37 -0600 Subject: [PATCH 02/25] Add install command, config, and options --- ThunderstoreCLI/Commands/InstallCommand.cs | 20 +++++++++++++++++++ .../Configuration/CLIParameterConfig.cs | 13 ++++++++++++ ThunderstoreCLI/Configuration/Config.cs | 12 +++++++++-- ThunderstoreCLI/Configuration/EmptyConfig.cs | 5 +++++ .../Configuration/IConfigProvider.cs | 1 + ThunderstoreCLI/Options.cs | 11 ++++++++++ ThunderstoreCLI/Plugins/PluginManager.cs | 11 ++++++++++ 7 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 ThunderstoreCLI/Commands/InstallCommand.cs create mode 100644 ThunderstoreCLI/Plugins/PluginManager.cs diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs new file mode 100644 index 0000000..fba2348 --- /dev/null +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -0,0 +1,20 @@ +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Plugins; + +namespace ThunderstoreCLI.Commands; + +public static class InstallCommand +{ + public static int Run(Config config) + { + throw new NotImplementedException(); + } + + public static int InstallLoader(Config config) + { + var managerTypes = PluginManager.GetAllOfType(); + + throw new NotImplementedException(); + } +} diff --git a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs index 9bc8fac..68a7795 100644 --- a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs +++ b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs @@ -77,3 +77,16 @@ public override AuthConfig GetAuthConfig() }; } } + +public class CLIInstallCommandConfig : CLIParameterConfig +{ + public CLIInstallCommandConfig(InstallOptions options) : base(options) { } + + public override InstallConfig? GetInstallConfig() + { + return new InstallConfig() + { + + }; + } +} diff --git a/ThunderstoreCLI/Configuration/Config.cs b/ThunderstoreCLI/Configuration/Config.cs index 28d3aac..22566c5 100644 --- a/ThunderstoreCLI/Configuration/Config.cs +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -13,12 +13,13 @@ public class Config public BuildConfig BuildConfig { get; private set; } public PublishConfig PublishConfig { get; private set; } public AuthConfig AuthConfig { get; private set; } + public InstallConfig InstallConfig { get; private set; } // ReSharper restore AutoPropertyCanBeMadeGetOnly.Local private readonly Lazy api; public ApiHelper Api => api.Value; - private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitConfig initConfig, BuildConfig buildConfig, PublishConfig publishConfig, AuthConfig authConfig) + private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitConfig initConfig, BuildConfig buildConfig, PublishConfig publishConfig, AuthConfig authConfig, InstallConfig installConfig) { api = new Lazy(() => new ApiHelper(this)); GeneralConfig = generalConfig; @@ -27,6 +28,7 @@ private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitCon BuildConfig = buildConfig; PublishConfig = publishConfig; AuthConfig = authConfig; + InstallConfig = installConfig; } public static Config FromCLI(IConfigProvider cliConfig) { @@ -115,7 +117,8 @@ public static Config Parse(IConfigProvider[] configProviders) var buildConfig = new BuildConfig(); var publishConfig = new PublishConfig(); var authConfig = new AuthConfig(); - var result = new Config(generalConfig, packageMeta, initConfig, buildConfig, publishConfig, authConfig); + var installConfig = new InstallConfig(); + var result = new Config(generalConfig, packageMeta, initConfig, buildConfig, publishConfig, authConfig, installConfig); foreach (var provider in configProviders) { provider.Parse(result); @@ -210,3 +213,8 @@ public class AuthConfig { public string? AuthToken { get; set; } } + +public class InstallConfig +{ + public string? ManagerIdentifier { get; set; } +} diff --git a/ThunderstoreCLI/Configuration/EmptyConfig.cs b/ThunderstoreCLI/Configuration/EmptyConfig.cs index 6c96b23..d4c23e8 100644 --- a/ThunderstoreCLI/Configuration/EmptyConfig.cs +++ b/ThunderstoreCLI/Configuration/EmptyConfig.cs @@ -33,4 +33,9 @@ public virtual void Parse(Config currentConfig) { } { return null; } + + public virtual InstallConfig? GetInstallConfig() + { + return null; + } } diff --git a/ThunderstoreCLI/Configuration/IConfigProvider.cs b/ThunderstoreCLI/Configuration/IConfigProvider.cs index e77644c..587e32c 100644 --- a/ThunderstoreCLI/Configuration/IConfigProvider.cs +++ b/ThunderstoreCLI/Configuration/IConfigProvider.cs @@ -10,4 +10,5 @@ public interface IConfigProvider BuildConfig? GetBuildConfig(); PublishConfig? GetPublishConfig(); AuthConfig? GetAuthConfig(); + InstallConfig? GetInstallConfig(); } diff --git a/ThunderstoreCLI/Options.cs b/ThunderstoreCLI/Options.cs index f1fff12..d2de2fb 100644 --- a/ThunderstoreCLI/Options.cs +++ b/ThunderstoreCLI/Options.cs @@ -149,3 +149,14 @@ public override int Execute() return PublishCommand.Run(Config.FromCLI(new CLIPublishCommandConfig(this))); } } + +[Verb("install", HelpText = "Installs a modloader or mod")] +public class InstallOptions : PackageOptions +{ + public string? ManagerId { get; set; } + + public override int Execute() + { + return InstallCommand.Run(Config.FromCLI(new CLIInstallCommandConfig(this))); + } +} diff --git a/ThunderstoreCLI/Plugins/PluginManager.cs b/ThunderstoreCLI/Plugins/PluginManager.cs new file mode 100644 index 0000000..be7ede4 --- /dev/null +++ b/ThunderstoreCLI/Plugins/PluginManager.cs @@ -0,0 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ThunderstoreCLI.Plugins; + +public static class PluginManager +{ + public static List GetAllOfType<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() + { + return typeof(PluginManager).Assembly.GetTypes().Where(x => x.IsAssignableTo(typeof(T))).ToList(); + } +} From 14e77e989cc6573ee7226238c6121688f5e1f60a Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Sat, 14 May 2022 21:23:39 -0500 Subject: [PATCH 03/25] Add Rust BepInEx installer impl --- ThunderstoreCLI/Commands/InstallCommand.cs | 8 - ThunderstoreCLI/Game/InstallHelpers.cs | 6 - ThunderstoreCLI/Game/ModManager.cs | 14 - ThunderstoreCLI/Plugins/PluginManager.cs | 11 - ThunderstoreCLI/Program.cs | 9 +- tcli-bepinex-installer/.gitignore | 1 + tcli-bepinex-installer/Cargo.lock | 651 +++++++++++++++++++++ tcli-bepinex-installer/Cargo.toml | 21 + tcli-bepinex-installer/src/main.rs | 243 ++++++++ 9 files changed, 924 insertions(+), 40 deletions(-) delete mode 100644 ThunderstoreCLI/Game/InstallHelpers.cs delete mode 100644 ThunderstoreCLI/Game/ModManager.cs delete mode 100644 ThunderstoreCLI/Plugins/PluginManager.cs create mode 100644 tcli-bepinex-installer/.gitignore create mode 100644 tcli-bepinex-installer/Cargo.lock create mode 100644 tcli-bepinex-installer/Cargo.toml create mode 100644 tcli-bepinex-installer/src/main.rs diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index fba2348..0ba1ffd 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -1,6 +1,5 @@ using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Game; -using ThunderstoreCLI.Plugins; namespace ThunderstoreCLI.Commands; @@ -10,11 +9,4 @@ public static int Run(Config config) { throw new NotImplementedException(); } - - public static int InstallLoader(Config config) - { - var managerTypes = PluginManager.GetAllOfType(); - - throw new NotImplementedException(); - } } diff --git a/ThunderstoreCLI/Game/InstallHelpers.cs b/ThunderstoreCLI/Game/InstallHelpers.cs deleted file mode 100644 index 6945e1e..0000000 --- a/ThunderstoreCLI/Game/InstallHelpers.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ThunderstoreCLI.Game; - -public static class InstallHelpers -{ - -} diff --git a/ThunderstoreCLI/Game/ModManager.cs b/ThunderstoreCLI/Game/ModManager.cs deleted file mode 100644 index d024c23..0000000 --- a/ThunderstoreCLI/Game/ModManager.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.IO.Compression; - -namespace ThunderstoreCLI.Game; - -public interface ModManager -{ - public static abstract string Identifier { get; } - public bool SupportsProfiles(GameDefinition gameDef); - public void InstallLoader(GameDefinition gameDef, ModProfile profile, PackageManifestV1 manifest, ZipArchive loaderArchive); - public void UninstallLoader(GameDefinition gameDef, ModProfile profile); - public void InstallMod(GameDefinition gameDef, ModProfile profile, PackageManifestV1 manifest, ZipArchive modArchive); - public void UninstallMod(GameDefinition gameDef, ModProfile profile, PackageManifestV1 manifest); - public int RunGame(GameDefinition gameDef, ModProfile profile); -} diff --git a/ThunderstoreCLI/Plugins/PluginManager.cs b/ThunderstoreCLI/Plugins/PluginManager.cs deleted file mode 100644 index be7ede4..0000000 --- a/ThunderstoreCLI/Plugins/PluginManager.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace ThunderstoreCLI.Plugins; - -public static class PluginManager -{ - public static List GetAllOfType<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>() - { - return typeof(PluginManager).Assembly.GetTypes().Where(x => x.IsAssignableTo(typeof(T))).ToList(); - } -} diff --git a/ThunderstoreCLI/Program.cs b/ThunderstoreCLI/Program.cs index bd6d660..9e00416 100644 --- a/ThunderstoreCLI/Program.cs +++ b/ThunderstoreCLI/Program.cs @@ -15,11 +15,18 @@ private static int Main(string[] args) #endif var updateChecker = UpdateChecker.CheckForUpdates(); - var exitCode = Parser.Default.ParseArguments(args) + var exitCode = Parser.Default.ParseArguments(args) .MapResult( (InitOptions o) => HandleParsed(o), (BuildOptions o) => HandleParsed(o), (PublishOptions o) => HandleParsed(o), +#if DEBUG + (InstallOptions o) => HandleParsed(o), +#endif _ => 1 // failure to parse ); UpdateChecker.WriteUpdateNotification(updateChecker); diff --git a/tcli-bepinex-installer/.gitignore b/tcli-bepinex-installer/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/tcli-bepinex-installer/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tcli-bepinex-installer/Cargo.lock b/tcli-bepinex-installer/Cargo.lock new file mode 100644 index 0000000..21ec841 --- /dev/null +++ b/tcli-bepinex-installer/Cargo.lock @@ -0,0 +1,651 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "opaque-debug", +] + +[[package]] +name = "anyhow" +version = "1.0.57" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc" + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "base64ct" +version = "1.0.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "8a32fd6af2b5827bce66c29053ba0e7c42b9dcab01835835058558c10851a46b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bzip2" +version = "0.4.3" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "6afcd980b5f3a45017c57e57a2fcccbb351cc43a356ce117ef760ef8052b89b0" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "clap" +version = "3.1.18" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b" +dependencies = [ + "atty", + "bitflags", + "clap_derive", + "clap_lex", + "indexmap", + "lazy_static", + "strsim", + "termcolor", + "textwrap", +] + +[[package]] +name = "clap_derive" +version = "3.1.18" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "25320346e922cffe59c0bbc5410c8d8784509efb321488971081313cb1e1a33c" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.2.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "cpufeatures" +version = "0.2.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.8" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" +dependencies = [ + "cfg-if", + "lazy_static", +] + +[[package]] +name = "crypto-common" +version = "0.1.3" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.3" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "flate2" +version = "1.0.23" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "b39522e96686d38f4bc984b9198e3a0613264abaebaff2c5c918bfa6b6da09af" +dependencies = [ + "cfg-if", + "crc32fast", + "libc", + "miniz_oxide", +] + +[[package]] +name = "generic-array" +version = "0.14.5" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "indexmap" +version = "1.8.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" + +[[package]] +name = "jobserver" +version = "0.1.24" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" +dependencies = [ + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.125" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "5916d2ae698f6de9bfb891ad7a8d65c09d232dc58cc4ac433c7da3b2fd84bc2b" + +[[package]] +name = "miniz_oxide" +version = "0.5.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" +dependencies = [ + "adler", +] + +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "os_str_bytes" +version = "6.0.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "029d8d0b2f198229de29dca79676f2738ff952edf3fde542eb8bf94d8c21b435" + +[[package]] +name = "password-hash" +version = "0.3.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "1d791538a6dcc1e7cb7fe6f6b58aca40e7f79403c45b2bc274008b5e647af1d8" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.10.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "271779f35b581956db91a3e55737327a03aa051e90b1c47aeb189508533adfd7" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.38" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "9027b48e9d4c9175fa2218adf3557f91c1137021739951d4932f5f8268ac48aa" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quote" +version = "1.0.18" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" + +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + +[[package]] +name = "serde" +version = "1.0.137" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.137" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.81" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "c77f4e7f65455545c2153c1253d25056825e77ee2533f0e41deb65a93a34852f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "syn" +version = "1.0.94" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "a07e33e919ebcd69113d5be0e4d70c5707004ff45188910106854f38b960df4a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "tcli-bepinex-installer" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", + "thiserror", + "zip", +] + +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.15.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" + +[[package]] +name = "thiserror" +version = "1.0.31" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.31" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.9" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "c2702e08a7a860f005826c6815dcac101b19b5eb330c27fe4a5928fec1d20ddd" +dependencies = [ + "itoa", + "libc", + "num_threads", + "time-macros", +] + +[[package]] +name = "time-macros" +version = "0.2.4" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" + +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + +[[package]] +name = "unicode-xid" +version = "0.2.3" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "zip" +version = "0.6.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "bf225bcf73bb52cbb496e70475c7bd7a3f769df699c0020f6c7bd9a96dcf0b8d" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.10.2+zstd.1.5.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "5f4a6bd64f22b5e3e94b4e238669ff9f10815c27a5180108b849d24174a83847" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "4.1.6+zstd.1.5.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "94b61c51bb270702d6167b8ce67340d2754b088d0c091b06e593aa772c3ee9bb" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "1.6.3+zstd.1.5.2" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "fc49afa5c8d634e75761feda8c592051e7eeb4683ba827211eb0d731d3402ea8" +dependencies = [ + "cc", + "libc", +] diff --git a/tcli-bepinex-installer/Cargo.toml b/tcli-bepinex-installer/Cargo.toml new file mode 100644 index 0000000..8cd5242 --- /dev/null +++ b/tcli-bepinex-installer/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tcli-bepinex-installer" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.57" +zip = "0.6.2" +thiserror = "1.0.31" +serde_json = "1.0.81" +serde = { version = "1.0.137", features = ["derive"] } + +[dependencies.clap] +version = "3.1.18" +features = ["derive", "cargo"] + +[profile.release] +lto = true +strip = true diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs new file mode 100644 index 0000000..7c3d0da --- /dev/null +++ b/tcli-bepinex-installer/src/main.rs @@ -0,0 +1,243 @@ +use std::{ + collections::HashMap, + ffi::OsString, + fs::{self, OpenOptions}, + io::{self, Read, Seek}, + path::{Path, PathBuf}, +}; + +use anyhow::{bail, Result}; +use clap::{Parser, Subcommand}; +use serde::Deserialize; +use zip::ZipArchive; + +#[derive(Parser)] +#[clap(author, version, about)] +struct Args { + #[clap(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Install { + game_directory: PathBuf, + bepinex_directory: PathBuf, + zip_path: PathBuf, + }, + Uninstall { + game_directory: PathBuf, + bepinex_directory: PathBuf, + name: String, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Path does not exist: {0}")] + PathDoesNotExist(PathBuf), + #[error("ZIP file does not contain a Thunderstore manifest")] + NoZipManifest, + #[error("Invalid manifest in ZIP")] + InvalidManifest, + #[error("Malformed zip")] + MalformedZip, + #[error("Manifest does not contain namespace in manifest, which is required for mod installs")] + MissingNamespace, +} + +#[derive(Deserialize)] +#[allow(unused)] +struct ManifestV1 { + pub namespace: Option, + pub name: String, + pub description: String, + pub version_number: String, + pub dependencies: Vec, + pub website_url: String, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + match args.command { + Commands::Install { + game_directory, + bepinex_directory, + zip_path, + } => { + if !game_directory.exists() { + bail!(Error::PathDoesNotExist(game_directory)); + } + if !bepinex_directory.exists() { + bail!(Error::PathDoesNotExist(bepinex_directory)); + } + if !zip_path.exists() { + bail!(Error::PathDoesNotExist(zip_path)); + } + install(game_directory, bepinex_directory, zip_path) + } + Commands::Uninstall { + game_directory, + bepinex_directory, + name, + } => { + if !game_directory.exists() { + bail!(Error::PathDoesNotExist(game_directory)); + } + if !bepinex_directory.exists() { + bail!(Error::PathDoesNotExist(bepinex_directory)); + } + uninstall(game_directory, bepinex_directory, name) + } + } +} + +fn install(game_dir: PathBuf, bep_dir: PathBuf, zip_path: PathBuf) -> Result<()> { + let mut zip = ZipArchive::new(std::fs::File::open(zip_path)?)?; + + if !zip.file_names().any(|name| name == "manifest.json") { + bail!(Error::NoZipManifest); + } + + let manifest_file = zip.by_name("manifest.json")?; + + let manifest: ManifestV1 = + serde_json::from_reader(manifest_file).map_err(|_| Error::InvalidManifest)?; + + if manifest.name.starts_with("BepInExPack") { + install_bepinex(game_dir, bep_dir, zip) + } else { + install_mod(bep_dir, zip, manifest) + } +} + +fn install_bepinex( + game_dir: PathBuf, + bep_dir: PathBuf, + mut zip: ZipArchive, +) -> Result<()> { + let write_opts = OpenOptions::new().write(true).create(true).clone(); + + for i in 0..zip.len() { + let mut file = zip.by_index(i)?; + + if file.is_dir() { + continue; + } + + let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); + + if !top_level_directory_name(&filepath) + .or(Some("".to_string())) + .unwrap() + .starts_with("BepInExPack") + { + continue; + } + + let dir_to_use = if filepath.ancestors().any(|part| { + part.file_name() + .or(Some(&OsString::new())) + .unwrap() + .to_string_lossy() + == "BepInEx" + }) { + &bep_dir + } else { + &game_dir + }; + + // this removes the BepInExPack*/ from the path + let resolved_path = remove_first_n_directories(&filepath, 1); + + fs::create_dir_all(dir_to_use.join(resolved_path.parent().unwrap()))?; + io::copy( + &mut file, + &mut write_opts.open(dir_to_use.join(resolved_path))?, + )?; + } + + Ok(()) +} + +fn install_mod( + bep_dir: PathBuf, + mut zip: ZipArchive, + manifest: ManifestV1, +) -> Result<()> { + let write_opts = OpenOptions::new().write(true).create(true).clone(); + + let full_name = format!( + "{}-{}", + manifest.namespace.ok_or(Error::MissingNamespace)?, + manifest.name + ); + + let mut remaps: HashMap<&str, PathBuf> = HashMap::new(); + remaps.insert( + "plugins", + Path::new("BepInEx").join("plugins").join(&full_name), + ); + remaps.insert( + "patchers", + Path::new("BepInEx").join("patchers").join(&full_name), + ); + remaps.insert( + "monomod", + Path::new("BepInEx").join("monomod").join(&full_name), + ); + remaps.insert("config", Path::new("BepInEx").join("config")); + + for i in 0..zip.len() { + let mut file = zip.by_index(i)?; + + if file.is_dir() { + continue; + } + + let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); + + let out_path: PathBuf = if let Some(root) = top_level_directory_name(&filepath) { + if let Some(remap) = remaps.get(&root as &str) { + remap.join(remove_first_n_directories(&filepath, 1)) + } else { + remaps["plugins"].join(filepath) + } + } else { + remaps["plugins"].join(filepath) + }; + + let full_out_path = bep_dir.join(out_path); + + fs::create_dir_all(full_out_path.parent().unwrap())?; + io::copy(&mut file, &mut write_opts.open(full_out_path)?)?; + } + + Ok(()) +} + +fn uninstall(game_dir: PathBuf, bep_dir: PathBuf, name: String) -> Result<()> { + todo!(); +} + +fn top_level_directory_name(path: &Path) -> Option { + path.ancestors() + .skip(1) + .filter(|x| !x.to_string_lossy().is_empty()) + .last() + .map(|root| root.to_string_lossy().to_string()) +} + +// removes the first n directories from a path, eg a/b/c/d.txt with an n of 2 gives c/d.txt +fn remove_first_n_directories(path: &Path, n: usize) -> PathBuf { + PathBuf::from_iter( + path.ancestors() + .collect::>() + .into_iter() + .rev() + .filter(|x| !x.to_string_lossy().is_empty()) + .skip(n) + .map(|part| part.file_name().unwrap()), + ) +} From b4c005cfff5ce9c95050161f074d27c8df737cf3 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Sun, 15 May 2022 18:38:55 -0500 Subject: [PATCH 04/25] Add uninstall logic --- tcli-bepinex-installer/src/main.rs | 61 ++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs index 7c3d0da..f4eacb3 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -42,8 +42,12 @@ pub enum Error { InvalidManifest, #[error("Malformed zip")] MalformedZip, - #[error("Manifest does not contain namespace in manifest, which is required for mod installs")] + #[error("Manifest does not contain a namespace, which is required for mod installs")] MissingNamespace, + #[error("Mod name is invalid (eg doesn't use a - between namespace and name)")] + InvalidModName, + #[error("Mod either is not installed or is not accessable to the uninstaller. Tried directory: {0}")] + ModNotInstalled(PathBuf), } #[derive(Deserialize)] @@ -218,7 +222,38 @@ fn install_mod( } fn uninstall(game_dir: PathBuf, bep_dir: PathBuf, name: String) -> Result<()> { - todo!(); + if name.split_once('-').ok_or(Error::InvalidModName)?.1.starts_with("BepInExPack") { + uninstall_bepinex(game_dir, bep_dir) + } else { + uninstall_mod(bep_dir, name) + } +} + +fn uninstall_bepinex(game_dir: PathBuf, bep_dir: PathBuf) -> Result<()> { + delete_file_if_not_deleted(game_dir.join("winhttp.dll"))?; + delete_file_if_not_deleted(game_dir.join("doorstop_config.ini"))?; + delete_file_if_not_deleted(game_dir.join("run_bepinex.sh"))?; + delete_dir_if_not_deleted(game_dir.join("doorstop_libs"))?; + delete_dir_if_not_deleted(bep_dir.join("BepInEx"))?; + + Ok(()) +} + +fn uninstall_mod(bep_dir: PathBuf, name: String) -> Result<()> { + let actual_bep = bep_dir.join("BepInEx"); + + let main_dir = actual_bep.join("plugins").join(&name); + + if !main_dir.exists() { + bail!(Error::ModNotInstalled(main_dir)); + } + + fs::remove_dir_all(main_dir)?; + + delete_dir_if_not_deleted(actual_bep.join("patchers"))?; + delete_dir_if_not_deleted(actual_bep.join("monomod").join(&name))?; + + Ok(()) } fn top_level_directory_name(path: &Path) -> Option { @@ -229,7 +264,7 @@ fn top_level_directory_name(path: &Path) -> Option { .map(|root| root.to_string_lossy().to_string()) } -// removes the first n directories from a path, eg a/b/c/d.txt with an n of 2 gives c/d.txt +/// removes the first n directories from a path, eg a/b/c/d.txt with an n of 2 gives c/d.txt fn remove_first_n_directories(path: &Path, n: usize) -> PathBuf { PathBuf::from_iter( path.ancestors() @@ -241,3 +276,23 @@ fn remove_first_n_directories(path: &Path, n: usize) -> PathBuf { .map(|part| part.file_name().unwrap()), ) } + +fn delete_file_if_not_deleted>(path: T) -> io::Result<()> { + match fs::remove_file(path) { + Ok(_) => Ok(()), + Err(e) => match e.kind() { + io::ErrorKind::NotFound => Ok(()), + _ => Err(e), + }, + } +} + +fn delete_dir_if_not_deleted>(path: T) -> io::Result<()> { + match fs::remove_dir_all(path) { + Ok(_) => Ok(()), + Err(e) => match e.kind() { + io::ErrorKind::NotFound => Ok(()), + _ => Err(e), + }, + } +} From 21f8ab780d1c17169d4b412398091e9e716abece Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Thu, 2 Jun 2022 00:43:52 -0500 Subject: [PATCH 05/25] Add install command for ror2 and vrising + derivatives also a ton of other busywork related to that --- ThunderstoreCLI/API/ApiHelper.cs | 10 +- ThunderstoreCLI/Commands/InitCommand.cs | 2 +- ThunderstoreCLI/Commands/InstallCommand.cs | 103 +++++++++++++++++- ThunderstoreCLI/Commands/PublishCommand.cs | 1 + ThunderstoreCLI/Configuration/BaseConfig.cs | 11 +- .../Configuration/CLIParameterConfig.cs | 11 +- ThunderstoreCLI/Configuration/Config.cs | 7 +- ThunderstoreCLI/Game/GameDefinition.cs | 56 ++++++++-- ThunderstoreCLI/Game/ModProfile.cs | 6 +- ThunderstoreCLI/Models/PublishModels.cs | 76 ++++++++++--- ThunderstoreCLI/Options.cs | 36 ++++-- ThunderstoreCLI/Program.cs | 8 +- ThunderstoreCLI/ThunderstoreCLI.csproj | 18 +++ ThunderstoreCLI/Utils/SteamUtils.cs | 103 ++++++++++++++++++ 14 files changed, 395 insertions(+), 53 deletions(-) create mode 100644 ThunderstoreCLI/Utils/SteamUtils.cs diff --git a/ThunderstoreCLI/API/ApiHelper.cs b/ThunderstoreCLI/API/ApiHelper.cs index 9f511b6..7dc3ef5 100644 --- a/ThunderstoreCLI/API/ApiHelper.cs +++ b/ThunderstoreCLI/API/ApiHelper.cs @@ -18,7 +18,7 @@ public class ApiHelper public ApiHelper(Config config) { Config = config; - BaseRequestBuilder = new RequestBuilder(config.PublishConfig.Repository ?? throw new Exception("The target repository cannot be empty")); + BaseRequestBuilder = new RequestBuilder(config.GeneralConfig.Repository ?? throw new Exception("Repository can't be empty")); authHeader = new Lazy(() => { if (string.IsNullOrEmpty(Config.AuthConfig.AuthToken)) @@ -73,6 +73,14 @@ public HttpRequestMessage AbortUploadMedia(string uuid) .GetRequest(); } + public HttpRequestMessage GetPackageMetadata(string author, string name) + { + return BaseRequestBuilder + .StartNew() + .WithEndpoint(EXPERIMENTAL + $"package/{author}/{name}/") + .GetRequest(); + } + private static string SerializeFileData(string filePath) { return new FileData() diff --git a/ThunderstoreCLI/Commands/InitCommand.cs b/ThunderstoreCLI/Commands/InitCommand.cs index 923be6e..6aaa02f 100644 --- a/ThunderstoreCLI/Commands/InitCommand.cs +++ b/ThunderstoreCLI/Commands/InitCommand.cs @@ -87,7 +87,7 @@ private static void ValidateConfig(Config config) v.AddIfEmpty(config.BuildConfig.IconPath, "Build IconPath"); v.AddIfEmpty(config.BuildConfig.ReadmePath, "Build ReadmePath"); v.AddIfEmpty(config.BuildConfig.OutDir, "Build OutDir"); - v.AddIfEmpty(config.PublishConfig.Repository, "Publish Repository"); + v.AddIfEmpty(config.GeneralConfig.Repository, "Publish Repository"); v.ThrowIfErrors(); } } diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index 0ba1ffd..25ac226 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -1,12 +1,113 @@ using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Game; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; namespace ThunderstoreCLI.Commands; public static class InstallCommand { + private static readonly Dictionary IDToHardcoded = new() + { + { "ror2", HardcodedGame.ROR2 }, + { "vrising", HardcodedGame.VRISING }, + { "vrising_dedicated", HardcodedGame.VRISING_SERVER }, + { "vrising_builtin", HardcodedGame.VRISING_SERVER_BUILTIN } + }; + + private static readonly Regex FullPackageNameRegex = new(@"^([a-zA-Z0-9_]+)-([a-zA-Z0-9_]+)$"); + public static int Run(Config config) { - throw new NotImplementedException(); + List defs = GameDefinition.GetGameDefinitions(config.GeneralConfig.TcliConfig); + GameDefinition? def = defs.FirstOrDefault(x => x.Identifier == config.InstallConfig.GameIdentifer); + if (def == null && IDToHardcoded.TryGetValue(config.InstallConfig.GameIdentifer!, out var hardcoded)) + { + def = GameDefinition.FromHardcodedIdentifier(config.GeneralConfig.TcliConfig, hardcoded); + defs.Add(def); + } + else + { + Write.ErrorExit($"Not configured for the game: {config.InstallConfig.GameIdentifer}"); + return 1; + } + + ModProfile? profile; + if (config.InstallConfig.Global!.Value) + { + profile = def.GlobalProfile; + } + else + { + profile = def.Profiles.FirstOrDefault(x => x.Name == config.InstallConfig.ProfileName); + } + profile ??= new ModProfile(def, false, config.InstallConfig.ProfileName!, config.GeneralConfig.TcliConfig); + + string zipPath = config.InstallConfig.Package!; + bool isTemp = false; + if (!File.Exists(zipPath)) + { + var match = FullPackageNameRegex.Match(zipPath); + if (!match.Success) + { + Write.ErrorExit($"Package name does not exist as a file and is not a valid package name (namespace-author): {zipPath}"); + } + HttpClient http = new(); + var packageResponse = http.Send(config.Api.GetPackageMetadata(match.Groups[1].Value, match.Groups[2].Value)); + using StreamReader responseReader = new(packageResponse.Content.ReadAsStream()); + if (!packageResponse.IsSuccessStatusCode) + { + Write.ErrorExit($"Could not find package {zipPath}, got:\n{responseReader.ReadToEnd()}"); + return 1; + } + var data = PackageData.Deserialize(responseReader.ReadToEnd()); + + zipPath = Path.GetTempFileName(); + isTemp = true; + using var outFile = File.OpenWrite(zipPath); + + using var downloadStream = http.Send(new HttpRequestMessage(HttpMethod.Get, data!.LatestVersion!.DownloadUrl)).Content.ReadAsStream(); + + downloadStream.CopyTo(outFile); + } + + string installerName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tcli-bepinex-installer.exe" : "tcli-bepinex-installer"; + var bepinexInstallerPath = Path.Combine(Path.GetDirectoryName(typeof(InstallCommand).Assembly.Location)!, installerName); + + ProcessStartInfo installerInfo = new(bepinexInstallerPath) + { + ArgumentList = + { + "install", + def.InstallDirectory, + profile.ProfileDirectory, + zipPath + }, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + Process installerProcess = Process.Start(installerInfo)!; + installerProcess.WaitForExit(); + + Write.Light(installerProcess.StandardOutput.ReadToEnd()); + string errors = installerProcess.StandardError.ReadToEnd(); + if (!string.IsNullOrWhiteSpace(errors)) + { + Write.Error(errors); + } + + if (isTemp) + { + File.Delete(zipPath); + } + + GameDefinition.SetGameDefinitions(config.GeneralConfig.TcliConfig, defs); + + return installerProcess.ExitCode; } } diff --git a/ThunderstoreCLI/Commands/PublishCommand.cs b/ThunderstoreCLI/Commands/PublishCommand.cs index 57e8886..a1965ae 100644 --- a/ThunderstoreCLI/Commands/PublishCommand.cs +++ b/ThunderstoreCLI/Commands/PublishCommand.cs @@ -4,6 +4,7 @@ using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Models; using ThunderstoreCLI.Utils; +using ThunderstoreCLI.Models; using static Crayon.Output; namespace ThunderstoreCLI.Commands; diff --git a/ThunderstoreCLI/Configuration/BaseConfig.cs b/ThunderstoreCLI/Configuration/BaseConfig.cs index c10d8c4..16f32a1 100644 --- a/ThunderstoreCLI/Configuration/BaseConfig.cs +++ b/ThunderstoreCLI/Configuration/BaseConfig.cs @@ -2,6 +2,14 @@ namespace ThunderstoreCLI.Configuration; class BaseConfig : EmptyConfig { + public override GeneralConfig? GetGeneralConfig() + { + return new GeneralConfig() + { + Repository = Defaults.REPOSITORY_URL + }; + } + public override PackageConfig GetPackageMeta() { return new PackageConfig() @@ -46,8 +54,7 @@ public override PublishConfig GetPublishConfig() { return new PublishConfig() { - File = null, - Repository = Defaults.REPOSITORY_URL + File = null }; } } diff --git a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs index 68a7795..67b93d3 100644 --- a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs +++ b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs @@ -64,8 +64,7 @@ public override PublishConfig GetPublishConfig() { return new PublishConfig() { - File = options.File, - Repository = options.Repository + File = options.File }; } @@ -78,7 +77,7 @@ public override AuthConfig GetAuthConfig() } } -public class CLIInstallCommandConfig : CLIParameterConfig +public class CLIInstallCommandConfig : BaseConfig { public CLIInstallCommandConfig(InstallOptions options) : base(options) { } @@ -86,7 +85,11 @@ public CLIInstallCommandConfig(InstallOptions options) : base(options) { } { return new InstallConfig() { - + //ManagerIdentifier = options.ManagerId + GameIdentifer = options.GameName, + Global = options.Global, + ProfileName = options.Profile, + Package = options.Package }; } } diff --git a/ThunderstoreCLI/Configuration/Config.cs b/ThunderstoreCLI/Configuration/Config.cs index 22566c5..2eff251 100644 --- a/ThunderstoreCLI/Configuration/Config.cs +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -204,7 +204,6 @@ public class BuildConfig public class PublishConfig { public string? File { get; set; } - public string? Repository { get; set; } public string[]? Communities { get; set; } public string[]? Categories { get; set; } } @@ -216,5 +215,9 @@ public class AuthConfig public class InstallConfig { - public string? ManagerIdentifier { get; set; } + public string? GameIdentifer { get; set; } + //public string? ManagerIdentifier { get; set; } + public string? Package { get; set; } + public bool? Global { get; set; } + public string? ProfileName { get; set; } } diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs index 9f8ab45..8049c23 100644 --- a/ThunderstoreCLI/Game/GameDefinition.cs +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; namespace ThunderstoreCLI.Game; @@ -9,28 +10,65 @@ public class GameDefinition : BaseJson private const string FILE_NAME = "GameDefintions.json"; public string Identifier { get; } public string Name { get; } - public string ModManager { get; } - public string InstallDirectory { get; } + public string InstallDirectory { get; private set; } public List Profiles { get; private set; } = new(); public ModProfile GlobalProfile { get; } - internal GameDefinition(string id, string name, string modManager, string tcliDirectory) +#pragma warning disable CS8618 + private GameDefinition() { } +#pragma warning restore CS8618 + + internal GameDefinition(string id, string name, string installDirectory, string tcliDirectory) { Identifier = id; Name = name; - ModManager = modManager; + InstallDirectory = installDirectory; GlobalProfile = new ModProfile(this, true, "Global", tcliDirectory); - // TODO: actually find install dir instead of manually setting the path in json - // yes im lazy } - public static List GetGameDefinitions(string tcliDirectory) + internal static List GetGameDefinitions(string tcliDirectory) + { + var filename = Path.Combine(tcliDirectory, FILE_NAME); + if (File.Exists(filename)) + return DeserializeList(File.ReadAllText(filename)) ?? new(); + else + return new(); + } + + internal static GameDefinition FromHardcodedIdentifier(string tcliDir, HardcodedGame game) + { + return game switch + { + HardcodedGame.ROR2 => FromSteamId(tcliDir, 632360, "ror2", "Risk of Rain 2"), + HardcodedGame.VRISING => FromSteamId(tcliDir, 1604030, "vrising", "V Rising"), + HardcodedGame.VRISING_SERVER => FromSteamId(tcliDir, 1829350, "vrising_server", "V Rising Dedicated Server"), + HardcodedGame.VRISING_SERVER_BUILTIN => FromSteamId(tcliDir, 1604030, "VRising_Server", "virsing_server_builtin", "V Rising Built-in Server"), + _ => throw new ArgumentException("Invalid enum value", nameof(game)) + }; + } + + internal static GameDefinition FromSteamId(string tcliDir, uint steamId, string id, string name) { - return DeserializeList(File.ReadAllText(Path.Combine(tcliDirectory, FILE_NAME))) ?? new(); + return new GameDefinition(id, name, SteamUtils.FindInstallDirectory(steamId), tcliDir); } - public static void SetGameDefinitions(string tcliDirectory, List list) + internal static GameDefinition FromSteamId(string tcliDir, uint steamId, string subdirectory, string id, string name) + { + var gameDef = FromSteamId(tcliDir, steamId, id, name); + gameDef.InstallDirectory = Path.Combine(gameDef.InstallDirectory, subdirectory); + return gameDef; + } + + internal static void SetGameDefinitions(string tcliDirectory, List list) { File.WriteAllText(Path.Combine(tcliDirectory, FILE_NAME), list.SerializeList(BaseJson.IndentedSettings)); } } + +internal enum HardcodedGame +{ + ROR2, + VRISING, + VRISING_SERVER, + VRISING_SERVER_BUILTIN +} diff --git a/ThunderstoreCLI/Game/ModProfile.cs b/ThunderstoreCLI/Game/ModProfile.cs index afb877c..1d45585 100644 --- a/ThunderstoreCLI/Game/ModProfile.cs +++ b/ThunderstoreCLI/Game/ModProfile.cs @@ -9,7 +9,10 @@ public class ModProfile : BaseJson public bool IsGlobal { get; } public string Name { get; } public string ProfileDirectory { get; } - public List InstalledMods { get; set; } = new(); + +#pragma warning disable CS8618 + private ModProfile() { } +#pragma warning restore CS8618 internal ModProfile(GameDefinition gameDef, bool global, string name, string tcliDirectory) { @@ -22,5 +25,6 @@ internal ModProfile(GameDefinition gameDef, bool global, string name, string tcl Directory.CreateDirectory(directory); } ProfileDirectory = directory; + gameDef.Profiles.Add(this); } } diff --git a/ThunderstoreCLI/Models/PublishModels.cs b/ThunderstoreCLI/Models/PublishModels.cs index 8ec7fb4..482fe83 100644 --- a/ThunderstoreCLI/Models/PublishModels.cs +++ b/ThunderstoreCLI/Models/PublishModels.cs @@ -100,35 +100,75 @@ public class CategoryData [JsonProperty("url")] public string? Url { get; set; } } - public class PackageVersionData - { - [JsonProperty("namespace")] public string? Namespace { get; set; } + [JsonProperty("available_communities")] + public List? AvailableCommunities { get; set; } - [JsonProperty("name")] public string? Name { get; set; } + [JsonProperty("package_version")] public PackageVersionData? PackageVersion { get; set; } +} - [JsonProperty("version_number")] public string? VersionNumber { get; set; } +public class PackageData : BaseJson +{ + [JsonProperty("namespace")] public string? Namespace { get; set; } - [JsonProperty("full_name")] public string? FullName { get; set; } + [JsonProperty("name")] public string? Name { get; set; } - [JsonProperty("description")] public string? Description { get; set; } + [JsonProperty("full_name")] public string? Fullname { get; set; } - [JsonProperty("icon")] public string? Icon { get; set; } + [JsonProperty("owner")] public string? Owner { get; set; } - [JsonProperty("dependencies")] public List? Dependencies { get; set; } + [JsonProperty("package_url")] public string? PackageUrl { get; set; } - [JsonProperty("download_url")] public string? DownloadUrl { get; set; } + [JsonProperty("date_created")] public DateTime DateCreated { get; set; } - [JsonProperty("downloads")] public int Downloads { get; set; } + [JsonProperty("date_updated")] public DateTime DateUpdated { get; set; } - [JsonProperty("date_created")] public DateTime DateCreated { get; set; } + [JsonProperty("rating_score")] public string? RatingScore { get; set; } - [JsonProperty("website_url")] public string? WebsiteUrl { get; set; } + [JsonProperty("is_pinned")] public bool IsPinned { get; set; } - [JsonProperty("is_active")] public bool IsActive { get; set; } - } + [JsonProperty("is_deprecated")] public bool IsDeprecated { get; set; } - [JsonProperty("available_communities")] - public List? AvailableCommunities { get; set; } + [JsonProperty("total_downloads")] public string? TotalDownloads { get; set; } - [JsonProperty("package_version")] public PackageVersionData? PackageVersion { get; set; } + [JsonProperty("latest")] public PackageVersionData? LatestVersion { get; set; } + + [JsonProperty("community_listings")] public PackageListingData[]? CommunityListings { get; set; } +} + +public class PackageVersionData : BaseJson +{ + [JsonProperty("namespace")] public string? Namespace { get; set; } + + [JsonProperty("name")] public string? Name { get; set; } + + [JsonProperty("version_number")] public string? VersionNumber { get; set; } + + [JsonProperty("full_name")] public string? FullName { get; set; } + + [JsonProperty("description")] public string? Description { get; set; } + + [JsonProperty("icon")] public string? Icon { get; set; } + + [JsonProperty("dependencies")] public List? Dependencies { get; set; } + + [JsonProperty("download_url")] public string? DownloadUrl { get; set; } + + [JsonProperty("downloads")] public int Downloads { get; set; } + + [JsonProperty("date_created")] public DateTime DateCreated { get; set; } + + [JsonProperty("website_url")] public string? WebsiteUrl { get; set; } + + [JsonProperty("is_active")] public bool IsActive { get; set; } +} + +public class PackageListingData : BaseJson +{ + [JsonProperty("has_nsfw_content")] public bool HasNsfwContent { get; set; } + + [JsonProperty("categories")] public string[]? Categories { get; set; } + + [JsonProperty("community")] public string? Community { get; set; } + + [JsonProperty("review_status")] public string? ReviewStatus { get; set; } } diff --git a/ThunderstoreCLI/Options.cs b/ThunderstoreCLI/Options.cs index d2de2fb..ff6f7a7 100644 --- a/ThunderstoreCLI/Options.cs +++ b/ThunderstoreCLI/Options.cs @@ -10,13 +10,16 @@ namespace ThunderstoreCLI; /// Options are arguments passed from command line. public abstract class BaseOptions { - [Option("output", Required = false, HelpText = "The output format for all output. Valid options are HUMAN and JSON.")] + [Option("output", Required = false, HelpText = "The output format for all output. Valid options are HUMAN and JSON. (does nothing)")] public InteractionOutputType OutputType { get; set; } = InteractionOutputType.HUMAN; [Option("tcli-directory", Required = false, HelpText = "Directory where TCLI keeps its data, %APPDATA%/ThunderstoreCLI on Windows and ~/.config/ThunderstoreCLI on Linux")] // will be initialized in Init if null public string TcliDirectory { get; set; } = null!; + [Option("repository", Required = false, HelpText = "URL of the default repository")] + public string Repository { get; set; } = null!; + public virtual void Init() { InteractionOptions.OutputType = OutputType; @@ -118,9 +121,6 @@ public class PublishOptions : PackageOptions [Option("token", Required = false, HelpText = "Authentication token to use for publishing.")] public string? Token { get; set; } - [Option("repository", Required = false, HelpText = "URL of the repository where to publish.")] - public string? Repository { get; set; } - public override bool Validate() { if (!base.Validate()) @@ -150,10 +150,32 @@ public override int Execute() } } -[Verb("install", HelpText = "Installs a modloader or mod")] -public class InstallOptions : PackageOptions +[Verb("install", HelpText = "Installs a mod")] +public class InstallOptions : BaseOptions { - public string? ManagerId { get; set; } + //public string? ManagerId { get; set; } + + [Value(0, MetaName = "Game Name", Required = true, HelpText = "Can be any of: ror2, vrising, vrising_dedicated, vrising_builtin")] + public string GameName { get; set; } = null!; + + [Value(1, MetaName = "Package", Required = true, HelpText = "Path to package zip or package name in the format namespace-name")] + public string Package { get; set; } = null!; + + [Option(HelpText = "Profile to install to", Default = "Default")] + public string? Profile { get; set; } + + [Option(HelpText = "Set to install mods globally instead of into a profile", Default = false)] + public bool Global { get; set; } + + public override bool Validate() + { +#if NOINSTALLERS + Write.ErrorExit("Installers are not supported when installed through dotnet tool (yet) (i hate nuget)"); + return false; +#endif + + return base.Validate(); + } public override int Execute() { diff --git a/ThunderstoreCLI/Program.cs b/ThunderstoreCLI/Program.cs index 9e00416..17b7ca1 100644 --- a/ThunderstoreCLI/Program.cs +++ b/ThunderstoreCLI/Program.cs @@ -15,18 +15,12 @@ private static int Main(string[] args) #endif var updateChecker = UpdateChecker.CheckForUpdates(); - var exitCode = Parser.Default.ParseArguments(args) + var exitCode = Parser.Default.ParseArguments(args) .MapResult( (InitOptions o) => HandleParsed(o), (BuildOptions o) => HandleParsed(o), (PublishOptions o) => HandleParsed(o), -#if DEBUG (InstallOptions o) => HandleParsed(o), -#endif _ => 1 // failure to parse ); UpdateChecker.WriteUpdateNotification(updateChecker); diff --git a/ThunderstoreCLI/ThunderstoreCLI.csproj b/ThunderstoreCLI/ThunderstoreCLI.csproj index 7176c8a..a3ddf9c 100644 --- a/ThunderstoreCLI/ThunderstoreCLI.csproj +++ b/ThunderstoreCLI/ThunderstoreCLI.csproj @@ -55,4 +55,22 @@ Resources.Designer.cs + + + + + --release + debug + release + + + + + + + + + + NOINSTALLERS + diff --git a/ThunderstoreCLI/Utils/SteamUtils.cs b/ThunderstoreCLI/Utils/SteamUtils.cs new file mode 100644 index 0000000..83ddf84 --- /dev/null +++ b/ThunderstoreCLI/Utils/SteamUtils.cs @@ -0,0 +1,103 @@ +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace ThunderstoreCLI.Utils; + +public static class SteamUtils +{ + public static string FindInstallDirectory(uint steamAppId) + { + string primarySteamApps = FindSteamAppsDirectory(); + List libraryPaths = new() { primarySteamApps }; + foreach (var file in Directory.EnumerateFiles(primarySteamApps)) + { + if (!Path.GetFileName(file).Equals("libraryfolders.vdf", StringComparison.OrdinalIgnoreCase)) + continue; + libraryPaths.AddRange(SteamAppsPathsRegex.Matches(File.ReadAllText(file)).Select(x => x.Groups[1].Value).Select(x => Path.Combine(x, "steamapps"))); + break; + } + + string acfName = $"appmanifest_{steamAppId}.acf"; + foreach (var library in libraryPaths) + { + foreach (var file in Directory.EnumerateFiles(library)) + { + if (!Path.GetFileName(file).Equals(acfName, StringComparison.OrdinalIgnoreCase)) + continue; + + var folderName = ManifestInstallLocationRegex.Match(File.ReadAllText(file)).Groups[1].Value; + + return Path.GetFullPath(Path.Combine(library, "common", folderName)); + } + } + throw new FileNotFoundException($"Could not find {acfName}, tried the following paths:\n{string.Join('\n', libraryPaths)}"); + } + + private static readonly Regex SteamAppsPathsRegex = new(@"""path""\s+""(.+)"""); + private static readonly Regex ManifestInstallLocationRegex = new(@"""installdir""\s+""(.+)"""); + + public static string FindSteamAppsDirectory() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return FindSteamAppsDirectoryWin(); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return FindSteamAppsDirectoryOsx(); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return FindSteamAppsDirectoryLinux(); + else + throw new NotSupportedException("Unknown operating system"); + } + private static string FindSteamAppsDirectoryWin() + { + throw new NotImplementedException(); + } + private static string FindSteamAppsDirectoryOsx() + { + throw new NotImplementedException(); + } + private static string FindSteamAppsDirectoryLinux() + { + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string[] possiblePaths = { + Path.Combine(homeDir, ".local", "share", "Steam"), + Path.Combine(homeDir, ".steam", "steam"), + Path.Combine(homeDir, ".steam", "root"), + Path.Combine(homeDir, ".steam"), + Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".local", "share", "Steam"), + Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".steam", "steam"), + Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".steam", "root"), + Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".steam") + }; + string steamPath = null!; + foreach (var path in possiblePaths) + { + if (Directory.Exists(path)) + { + steamPath = path; + goto FoundSteam; + } + } + throw new DirectoryNotFoundException($"Could not find Steam directory, tried these paths:\n{string.Join('\n', possiblePaths)}"); +FoundSteam: + + possiblePaths = new[] + { + Path.Combine(steamPath, "steamapps"), // most distros + Path.Combine(steamPath, "steam", "steamapps"), // ubuntu apparently + Path.Combine(steamPath, "root", "steamapps"), // no idea + }; + string steamAppsPath = null!; + foreach (var path in possiblePaths) + { + if (Directory.Exists(path)) + { + steamAppsPath = path; + goto FoundSteamApps; + } + } + throw new DirectoryNotFoundException($"Could not find steamapps directory, tried these paths:\n{string.Join('\n', possiblePaths)}"); +FoundSteamApps: + + return steamAppsPath; + } +} From 1b8fb3297419f0c398e1ed0b3abc6a33b46df401 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Mon, 3 Oct 2022 21:40:34 -0500 Subject: [PATCH 06/25] Beginnings of mod installation --- ThunderstoreCLI/Commands/BuildCommand.cs | 1 - ThunderstoreCLI/Commands/InstallCommand.cs | 131 ++++++++----- ThunderstoreCLI/Commands/PublishCommand.cs | 35 +--- ThunderstoreCLI/Commands/UninstallCommand.cs | 11 ++ .../Configuration/CLIParameterConfig.cs | 11 +- ThunderstoreCLI/Configuration/Config.cs | 10 +- ThunderstoreCLI/Configuration/EmptyConfig.cs | 2 +- .../Configuration/IConfigProvider.cs | 2 +- .../Configuration/ProjectFileConfig.cs | 1 + ThunderstoreCLI/Game/GameDefinition.cs | 4 +- ThunderstoreCLI/Game/ModProfile.cs | 2 + ThunderstoreCLI/Models/BaseJson.cs | 5 + .../Models/Interaction/BaseInteraction.cs | 38 ---- ThunderstoreCLI/Options.cs | 44 +++-- ThunderstoreCLI/Plugins/PluginManager.cs | 25 +++ ThunderstoreCLI/Program.cs | 20 +- ThunderstoreCLI/ThunderstoreCLI.csproj | 1 + .../Utils/CommandFatalException.cs | 10 + ThunderstoreCLI/Utils/ModDependencyTree.cs | 47 +++++ tcli-bepinex-installer/Cargo.lock | 173 +++++++----------- tcli-bepinex-installer/Cargo.toml | 10 +- tcli-bepinex-installer/src/main.rs | 18 +- 22 files changed, 345 insertions(+), 256 deletions(-) create mode 100644 ThunderstoreCLI/Commands/UninstallCommand.cs delete mode 100644 ThunderstoreCLI/Models/Interaction/BaseInteraction.cs create mode 100644 ThunderstoreCLI/Plugins/PluginManager.cs create mode 100644 ThunderstoreCLI/Utils/CommandFatalException.cs create mode 100644 ThunderstoreCLI/Utils/ModDependencyTree.cs diff --git a/ThunderstoreCLI/Commands/BuildCommand.cs b/ThunderstoreCLI/Commands/BuildCommand.cs index 1d667dc..b37abb1 100644 --- a/ThunderstoreCLI/Commands/BuildCommand.cs +++ b/ThunderstoreCLI/Commands/BuildCommand.cs @@ -1,6 +1,5 @@ using System.IO.Compression; using System.Text; -using Newtonsoft.Json; using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Models; using ThunderstoreCLI.Utils; diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index 25ac226..3bc1a05 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -1,8 +1,11 @@ using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Game; +using System.Collections.ObjectModel; using System.Diagnostics; +using System.IO.Compression; using System.Runtime.InteropServices; using System.Text.RegularExpressions; +using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Game; using ThunderstoreCLI.Models; using ThunderstoreCLI.Utils; @@ -11,70 +14,116 @@ namespace ThunderstoreCLI.Commands; public static class InstallCommand { - private static readonly Dictionary IDToHardcoded = new() + internal static readonly Dictionary IDToHardcoded = new() { { "ror2", HardcodedGame.ROR2 }, { "vrising", HardcodedGame.VRISING }, - { "vrising_dedicated", HardcodedGame.VRISING_SERVER }, - { "vrising_builtin", HardcodedGame.VRISING_SERVER_BUILTIN } + { "vrising_dedicated", HardcodedGame.VRISING_SERVER } }; - private static readonly Regex FullPackageNameRegex = new(@"^([a-zA-Z0-9_]+)-([a-zA-Z0-9_]+)$"); + internal static readonly Regex FullPackageNameRegex = new(@"^([a-zA-Z0-9_]+)-([a-zA-Z0-9_]+)$"); - public static int Run(Config config) + public static async Task Run(Config config) { List defs = GameDefinition.GetGameDefinitions(config.GeneralConfig.TcliConfig); - GameDefinition? def = defs.FirstOrDefault(x => x.Identifier == config.InstallConfig.GameIdentifer); - if (def == null && IDToHardcoded.TryGetValue(config.InstallConfig.GameIdentifer!, out var hardcoded)) + GameDefinition? def = defs.FirstOrDefault(x => x.Identifier == config.ModManagementConfig.GameIdentifer); + if (def == null && IDToHardcoded.TryGetValue(config.ModManagementConfig.GameIdentifer!, out var hardcoded)) { def = GameDefinition.FromHardcodedIdentifier(config.GeneralConfig.TcliConfig, hardcoded); defs.Add(def); } else { - Write.ErrorExit($"Not configured for the game: {config.InstallConfig.GameIdentifer}"); + Write.ErrorExit($"Not configured for the game: {config.ModManagementConfig.GameIdentifer}"); return 1; } ModProfile? profile; - if (config.InstallConfig.Global!.Value) + if (config.ModManagementConfig.Global!.Value) { profile = def.GlobalProfile; } else { - profile = def.Profiles.FirstOrDefault(x => x.Name == config.InstallConfig.ProfileName); + profile = def.Profiles.FirstOrDefault(x => x.Name == config.ModManagementConfig.ProfileName); } - profile ??= new ModProfile(def, false, config.InstallConfig.ProfileName!, config.GeneralConfig.TcliConfig); + profile ??= new ModProfile(def, false, config.ModManagementConfig.ProfileName!, config.GeneralConfig.TcliConfig); + + string package = config.ModManagementConfig.Package!; + + HttpClient http = new(); - string zipPath = config.InstallConfig.Package!; - bool isTemp = false; - if (!File.Exists(zipPath)) + int returnCode; + if (File.Exists(package)) { - var match = FullPackageNameRegex.Match(zipPath); - if (!match.Success) - { - Write.ErrorExit($"Package name does not exist as a file and is not a valid package name (namespace-author): {zipPath}"); - } - HttpClient http = new(); - var packageResponse = http.Send(config.Api.GetPackageMetadata(match.Groups[1].Value, match.Groups[2].Value)); - using StreamReader responseReader = new(packageResponse.Content.ReadAsStream()); - if (!packageResponse.IsSuccessStatusCode) - { - Write.ErrorExit($"Could not find package {zipPath}, got:\n{responseReader.ReadToEnd()}"); - return 1; - } - var data = PackageData.Deserialize(responseReader.ReadToEnd()); + returnCode = await InstallZip(config, http, def, profile, package, null); + } + else if (FullPackageNameRegex.IsMatch(package)) + { + returnCode = await InstallFromRepository(config, http, def, profile, package); + } + else + { + throw new CommandFatalException($"Package given does not exist as a zip and is not a valid package identifier (namespace-name): {package}"); + } - zipPath = Path.GetTempFileName(); - isTemp = true; - using var outFile = File.OpenWrite(zipPath); + if (returnCode != 0) + return returnCode; - using var downloadStream = http.Send(new HttpRequestMessage(HttpMethod.Get, data!.LatestVersion!.DownloadUrl)).Content.ReadAsStream(); + GameDefinition.SetGameDefinitions(config.GeneralConfig.TcliConfig, defs); + + return 0; + } - downloadStream.CopyTo(outFile); + private static async Task InstallFromRepository(Config config, HttpClient http, GameDefinition game, ModProfile profile, string packageId) + { + var packageParts = packageId.Split('-'); + var packageResponse = await http.SendAsync(config.Api.GetPackageMetadata(packageParts[0], packageParts[1])); + packageResponse.EnsureSuccessStatusCode(); + var package = (await PackageData.DeserializeAsync(await packageResponse.Content.ReadAsStreamAsync()))!; + var tempZipPath = await DownloadTemp(http, package); + var returnCode = await InstallZip(config, http, game, profile, tempZipPath, package.Namespace); + File.Delete(tempZipPath); + return returnCode; + } + + private static async Task InstallZip(Config config, HttpClient http, GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace) + { + using var zip = ZipFile.OpenRead(zipPath); + var manifestFile = zip.GetEntry("manifest.json") ?? throw new CommandFatalException("Package zip needs a manifest.json!"); + var manifest = await PackageManifestV1.DeserializeAsync(manifestFile.Open()) + ?? throw new CommandFatalException("Package manifest.json is invalid! Please check against https://thunderstore.io/tools/manifest-v1-validator/"); + + var modsToInstall = ModDependencyTree.Generate(config, http, manifest).ToArray(); + + var downloadTasks = modsToInstall.Select(mod => DownloadTemp(http, mod)).ToArray(); + var spinner = new ProgressSpinner("mods downloaded", downloadTasks); + await spinner.Start(); + + foreach (var (tempZipPath, package) in downloadTasks.Select(x => x.Result).Zip(modsToInstall)) + { + int returnCode = RunInstaller(game, profile, tempZipPath, package.Namespace); + File.Delete(tempZipPath); + if (returnCode != 0) + return returnCode; } + return RunInstaller(game, profile, zipPath, backupNamespace); + } + + private static async Task DownloadTemp(HttpClient http, PackageData package) + { + string path = Path.GetTempFileName(); + await using var file = File.OpenWrite(path); + using var response = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, package.LatestVersion!.DownloadUrl!)); + response.EnsureSuccessStatusCode(); + var zipStream = await response.Content.ReadAsStreamAsync(); + await zipStream.CopyToAsync(file); + return path; + } + + private static int RunInstaller(GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace) + { string installerName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tcli-bepinex-installer.exe" : "tcli-bepinex-installer"; var bepinexInstallerPath = Path.Combine(Path.GetDirectoryName(typeof(InstallCommand).Assembly.Location)!, installerName); @@ -83,15 +132,20 @@ public static int Run(Config config) ArgumentList = { "install", - def.InstallDirectory, + game.InstallDirectory, profile.ProfileDirectory, zipPath }, RedirectStandardOutput = true, RedirectStandardError = true }; + if (backupNamespace != null) + { + installerInfo.ArgumentList.Add("--namespace-backup"); + installerInfo.ArgumentList.Add(backupNamespace); + } - Process installerProcess = Process.Start(installerInfo)!; + var installerProcess = Process.Start(installerInfo)!; installerProcess.WaitForExit(); Write.Light(installerProcess.StandardOutput.ReadToEnd()); @@ -101,13 +155,6 @@ public static int Run(Config config) Write.Error(errors); } - if (isTemp) - { - File.Delete(zipPath); - } - - GameDefinition.SetGameDefinitions(config.GeneralConfig.TcliConfig, defs); - return installerProcess.ExitCode; } } diff --git a/ThunderstoreCLI/Commands/PublishCommand.cs b/ThunderstoreCLI/Commands/PublishCommand.cs index a1965ae..ed00940 100644 --- a/ThunderstoreCLI/Commands/PublishCommand.cs +++ b/ThunderstoreCLI/Commands/PublishCommand.cs @@ -209,41 +209,14 @@ private static void PublishPackageRequest(Config config, string uploadUuid) stream.Seek(part.Offset, SeekOrigin.Begin); byte[] hash; - var chunk = new MemoryStream(); - const int blocksize = 65536; - - using (var reader = new BinaryReader(stream, Encoding.Default, true)) + using (var md5 = MD5.Create()) { - using (var md5 = MD5.Create()) - { - md5.Initialize(); - var length = part.Length; - - while (length > blocksize) - { - length -= blocksize; - var bytes = reader.ReadBytes(blocksize); - md5.TransformBlock(bytes, 0, blocksize, null, 0); - await chunk.WriteAsync(bytes); - } - - var finalBytes = reader.ReadBytes(length); - md5.TransformFinalBlock(finalBytes, 0, length); - - if (md5.Hash is null) - { - Write.ErrorExit($"MD5 hashing failed for part #{part.PartNumber})"); - throw new PublishCommandException(); - } - - hash = md5.Hash; - await chunk.WriteAsync(finalBytes); - chunk.Position = 0; - } + hash = await md5.ComputeHashAsync(stream); } var request = new HttpRequestMessage(HttpMethod.Put, part.Url); - request.Content = new StreamContent(chunk); + stream.Seek(part.Offset, SeekOrigin.Begin); + request.Content = new StreamContent(stream); request.Content.Headers.ContentMD5 = hash; request.Content.Headers.ContentLength = part.Length; diff --git a/ThunderstoreCLI/Commands/UninstallCommand.cs b/ThunderstoreCLI/Commands/UninstallCommand.cs new file mode 100644 index 0000000..d3d97cb --- /dev/null +++ b/ThunderstoreCLI/Commands/UninstallCommand.cs @@ -0,0 +1,11 @@ +using ThunderstoreCLI.Configuration; + +namespace ThunderstoreCLI.Commands; + +public static class UninstallCommand +{ + public static int Run(Config config) + { + return 0; + } +} diff --git a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs index 67b93d3..c9626bb 100644 --- a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs +++ b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs @@ -13,7 +13,8 @@ public override GeneralConfig GetGeneralConfig() { return new GeneralConfig() { - TcliConfig = options.TcliDirectory + TcliConfig = options.TcliDirectory, + Repository = options.Repository }; } } @@ -77,13 +78,13 @@ public override AuthConfig GetAuthConfig() } } -public class CLIInstallCommandConfig : BaseConfig +public class ModManagementCommandConfig : BaseConfig { - public CLIInstallCommandConfig(InstallOptions options) : base(options) { } + public ModManagementCommandConfig(ModManagementOptions options) : base(options) { } - public override InstallConfig? GetInstallConfig() + public override ModManagementConfig? GetInstallConfig() { - return new InstallConfig() + return new ModManagementConfig() { //ManagerIdentifier = options.ManagerId GameIdentifer = options.GameName, diff --git a/ThunderstoreCLI/Configuration/Config.cs b/ThunderstoreCLI/Configuration/Config.cs index 2eff251..9dba97c 100644 --- a/ThunderstoreCLI/Configuration/Config.cs +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -13,13 +13,13 @@ public class Config public BuildConfig BuildConfig { get; private set; } public PublishConfig PublishConfig { get; private set; } public AuthConfig AuthConfig { get; private set; } - public InstallConfig InstallConfig { get; private set; } + public ModManagementConfig ModManagementConfig { get; private set; } // ReSharper restore AutoPropertyCanBeMadeGetOnly.Local private readonly Lazy api; public ApiHelper Api => api.Value; - private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitConfig initConfig, BuildConfig buildConfig, PublishConfig publishConfig, AuthConfig authConfig, InstallConfig installConfig) + private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitConfig initConfig, BuildConfig buildConfig, PublishConfig publishConfig, AuthConfig authConfig, ModManagementConfig modManagementConfig) { api = new Lazy(() => new ApiHelper(this)); GeneralConfig = generalConfig; @@ -28,7 +28,7 @@ private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitCon BuildConfig = buildConfig; PublishConfig = publishConfig; AuthConfig = authConfig; - InstallConfig = installConfig; + ModManagementConfig = modManagementConfig; } public static Config FromCLI(IConfigProvider cliConfig) { @@ -117,7 +117,7 @@ public static Config Parse(IConfigProvider[] configProviders) var buildConfig = new BuildConfig(); var publishConfig = new PublishConfig(); var authConfig = new AuthConfig(); - var installConfig = new InstallConfig(); + var installConfig = new ModManagementConfig(); var result = new Config(generalConfig, packageMeta, initConfig, buildConfig, publishConfig, authConfig, installConfig); foreach (var provider in configProviders) { @@ -213,7 +213,7 @@ public class AuthConfig public string? AuthToken { get; set; } } -public class InstallConfig +public class ModManagementConfig { public string? GameIdentifer { get; set; } //public string? ManagerIdentifier { get; set; } diff --git a/ThunderstoreCLI/Configuration/EmptyConfig.cs b/ThunderstoreCLI/Configuration/EmptyConfig.cs index d4c23e8..e348f6c 100644 --- a/ThunderstoreCLI/Configuration/EmptyConfig.cs +++ b/ThunderstoreCLI/Configuration/EmptyConfig.cs @@ -34,7 +34,7 @@ public virtual void Parse(Config currentConfig) { } return null; } - public virtual InstallConfig? GetInstallConfig() + public virtual ModManagementConfig? GetInstallConfig() { return null; } diff --git a/ThunderstoreCLI/Configuration/IConfigProvider.cs b/ThunderstoreCLI/Configuration/IConfigProvider.cs index 587e32c..71ab859 100644 --- a/ThunderstoreCLI/Configuration/IConfigProvider.cs +++ b/ThunderstoreCLI/Configuration/IConfigProvider.cs @@ -10,5 +10,5 @@ public interface IConfigProvider BuildConfig? GetBuildConfig(); PublishConfig? GetPublishConfig(); AuthConfig? GetAuthConfig(); - InstallConfig? GetInstallConfig(); + ModManagementConfig? GetInstallConfig(); } diff --git a/ThunderstoreCLI/Configuration/ProjectFileConfig.cs b/ThunderstoreCLI/Configuration/ProjectFileConfig.cs index 731cf38..2afec61 100644 --- a/ThunderstoreCLI/Configuration/ProjectFileConfig.cs +++ b/ThunderstoreCLI/Configuration/ProjectFileConfig.cs @@ -1,4 +1,5 @@ using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; using static Crayon.Output; namespace ThunderstoreCLI.Configuration; diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs index 8049c23..89e550d 100644 --- a/ThunderstoreCLI/Game/GameDefinition.cs +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -42,7 +42,6 @@ internal static GameDefinition FromHardcodedIdentifier(string tcliDir, Hardcoded HardcodedGame.ROR2 => FromSteamId(tcliDir, 632360, "ror2", "Risk of Rain 2"), HardcodedGame.VRISING => FromSteamId(tcliDir, 1604030, "vrising", "V Rising"), HardcodedGame.VRISING_SERVER => FromSteamId(tcliDir, 1829350, "vrising_server", "V Rising Dedicated Server"), - HardcodedGame.VRISING_SERVER_BUILTIN => FromSteamId(tcliDir, 1604030, "VRising_Server", "virsing_server_builtin", "V Rising Built-in Server"), _ => throw new ArgumentException("Invalid enum value", nameof(game)) }; } @@ -69,6 +68,5 @@ internal enum HardcodedGame { ROR2, VRISING, - VRISING_SERVER, - VRISING_SERVER_BUILTIN + VRISING_SERVER } diff --git a/ThunderstoreCLI/Game/ModProfile.cs b/ThunderstoreCLI/Game/ModProfile.cs index 1d45585..a86f8c6 100644 --- a/ThunderstoreCLI/Game/ModProfile.cs +++ b/ThunderstoreCLI/Game/ModProfile.cs @@ -9,6 +9,7 @@ public class ModProfile : BaseJson public bool IsGlobal { get; } public string Name { get; } public string ProfileDirectory { get; } + public List InstalledMods { get; } #pragma warning disable CS8618 private ModProfile() { } @@ -18,6 +19,7 @@ internal ModProfile(GameDefinition gameDef, bool global, string name, string tcl { IsGlobal = global; Name = name; + InstalledMods = new(); var directory = Path.Combine(tcliDirectory, "Profiles", gameDef.Identifier, name); if (!Directory.Exists(directory)) diff --git a/ThunderstoreCLI/Models/BaseJson.cs b/ThunderstoreCLI/Models/BaseJson.cs index 829a3d2..2d509cc 100644 --- a/ThunderstoreCLI/Models/BaseJson.cs +++ b/ThunderstoreCLI/Models/BaseJson.cs @@ -13,6 +13,11 @@ public string Serialize(JsonSerializerSettings? options) } public static T? Deserialize(string json) => Deserialize(json, null); + public static T? Deserialize(Stream stream) + { + using var reader = new StreamReader(stream); + return JsonConvert.DeserializeObject(reader.ReadToEnd()); + } public static T? Deserialize(string json, JsonSerializerSettings? options) { return JsonConvert.DeserializeObject(json, options); diff --git a/ThunderstoreCLI/Models/Interaction/BaseInteraction.cs b/ThunderstoreCLI/Models/Interaction/BaseInteraction.cs deleted file mode 100644 index e734375..0000000 --- a/ThunderstoreCLI/Models/Interaction/BaseInteraction.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace ThunderstoreCLI.Models.Interaction; - -public enum InteractionOutputType -{ - HUMAN, - JSON, -} - -public static class InteractionOptions -{ - public static InteractionOutputType OutputType { get; set; } = InteractionOutputType.HUMAN; -} - -public abstract class BaseInteraction<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T> : BaseJson - where T : BaseInteraction -{ - public abstract string GetHumanString(); - - public string GetString() - { - switch (InteractionOptions.OutputType) - { - case InteractionOutputType.HUMAN: - return GetHumanString(); - case InteractionOutputType.JSON: - return Serialize(); - default: - throw new NotSupportedException(); - } - } - - public void Write() - { - Console.WriteLine(GetString()); - } -} diff --git a/ThunderstoreCLI/Options.cs b/ThunderstoreCLI/Options.cs index ff6f7a7..f2e9fb9 100644 --- a/ThunderstoreCLI/Options.cs +++ b/ThunderstoreCLI/Options.cs @@ -1,7 +1,6 @@ using CommandLine; using ThunderstoreCLI.Commands; using ThunderstoreCLI.Configuration; -using ThunderstoreCLI.Models.Interaction; using ThunderstoreCLI.Utils; using static Crayon.Output; @@ -10,9 +9,6 @@ namespace ThunderstoreCLI; /// Options are arguments passed from command line. public abstract class BaseOptions { - [Option("output", Required = false, HelpText = "The output format for all output. Valid options are HUMAN and JSON. (does nothing)")] - public InteractionOutputType OutputType { get; set; } = InteractionOutputType.HUMAN; - [Option("tcli-directory", Required = false, HelpText = "Directory where TCLI keeps its data, %APPDATA%/ThunderstoreCLI on Windows and ~/.config/ThunderstoreCLI on Linux")] // will be initialized in Init if null public string TcliDirectory { get; set; } = null!; @@ -20,11 +16,13 @@ public abstract class BaseOptions [Option("repository", Required = false, HelpText = "URL of the default repository")] public string Repository { get; set; } = null!; + [Option("config-path", Required = false, Default = Defaults.PROJECT_CONFIG_PATH, HelpText = "Path for the project configuration file")] + public string? ConfigPath { get; set; } + public virtual void Init() { - InteractionOptions.OutputType = OutputType; - // ReSharper disable once ConstantNullCoalescingCondition + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract TcliDirectory ??= Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ThunderstoreCLI"); } @@ -43,9 +41,6 @@ public virtual bool Validate() public abstract class PackageOptions : BaseOptions { - [Option("config-path", Required = false, Default = Defaults.PROJECT_CONFIG_PATH, HelpText = "Path for the project configuration file")] - public string? ConfigPath { get; set; } - [Option("package-name", SetName = "build", Required = false, HelpText = "Name for the package")] public string? Name { get; set; } @@ -150,8 +145,7 @@ public override int Execute() } } -[Verb("install", HelpText = "Installs a mod")] -public class InstallOptions : BaseOptions +public abstract class ModManagementOptions : BaseOptions { //public string? ManagerId { get; set; } @@ -167,6 +161,14 @@ public class InstallOptions : BaseOptions [Option(HelpText = "Set to install mods globally instead of into a profile", Default = false)] public bool Global { get; set; } + protected enum CommandInner + { + Install, + Uninstall + } + + protected abstract CommandInner CommandType { get; } + public override bool Validate() { #if NOINSTALLERS @@ -179,6 +181,24 @@ public override bool Validate() public override int Execute() { - return InstallCommand.Run(Config.FromCLI(new CLIInstallCommandConfig(this))); + var config = Config.FromCLI(new ModManagementCommandConfig(this)); + return CommandType switch + { + CommandInner.Install => InstallCommand.Run(config).GetAwaiter().GetResult(), + CommandInner.Uninstall => UninstallCommand.Run(config), + _ => throw new NotSupportedException() + }; } } + +[Verb("install")] +public class InstallOptions : ModManagementOptions +{ + protected override CommandInner CommandType => CommandInner.Install; +} + +[Verb("uninstall")] +public class UninstallOptions : ModManagementOptions +{ + protected override CommandInner CommandType => CommandInner.Uninstall; +} diff --git a/ThunderstoreCLI/Plugins/PluginManager.cs b/ThunderstoreCLI/Plugins/PluginManager.cs new file mode 100644 index 0000000..c89a8eb --- /dev/null +++ b/ThunderstoreCLI/Plugins/PluginManager.cs @@ -0,0 +1,25 @@ +using ThunderstoreCLI.Configuration; + +namespace ThunderstoreCLI.Plugins; + +public class PluginManager +{ + private class Plugin + { + + } + + private class PluginInfo + { + + } + + public string PluginDirectory { get; } + + private List LoadedPlugins = new(); + + public PluginManager(GeneralConfig config) + { + PluginDirectory = Path.Combine(config.TcliConfig, "Plugins"); + } +} diff --git a/ThunderstoreCLI/Program.cs b/ThunderstoreCLI/Program.cs index 17b7ca1..c0ca4e6 100644 --- a/ThunderstoreCLI/Program.cs +++ b/ThunderstoreCLI/Program.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using CommandLine; using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; namespace ThunderstoreCLI; @@ -15,12 +16,13 @@ private static int Main(string[] args) #endif var updateChecker = UpdateChecker.CheckForUpdates(); - var exitCode = Parser.Default.ParseArguments(args) + var exitCode = Parser.Default.ParseArguments(args) .MapResult( (InitOptions o) => HandleParsed(o), (BuildOptions o) => HandleParsed(o), (PublishOptions o) => HandleParsed(o), (InstallOptions o) => HandleParsed(o), + (UninstallOptions o) => HandleParsed(o), _ => 1 // failure to parse ); UpdateChecker.WriteUpdateNotification(updateChecker); @@ -31,8 +33,22 @@ private static int HandleParsed(BaseOptions parsed) { parsed.Init(); if (!parsed.Validate()) + { return 1; - return parsed.Execute(); + } + try + { + return parsed.Execute(); + } + catch (CommandFatalException cfe) + { + Write.Error(cfe.ErrorMessage); +#if DEBUG + throw; +#else + return 1; +#endif + } } } diff --git a/ThunderstoreCLI/ThunderstoreCLI.csproj b/ThunderstoreCLI/ThunderstoreCLI.csproj index a3ddf9c..e22810c 100644 --- a/ThunderstoreCLI/ThunderstoreCLI.csproj +++ b/ThunderstoreCLI/ThunderstoreCLI.csproj @@ -23,6 +23,7 @@ true $(AssemblyName) enable + true diff --git a/ThunderstoreCLI/Utils/CommandFatalException.cs b/ThunderstoreCLI/Utils/CommandFatalException.cs new file mode 100644 index 0000000..8e9434b --- /dev/null +++ b/ThunderstoreCLI/Utils/CommandFatalException.cs @@ -0,0 +1,10 @@ +namespace ThunderstoreCLI.Utils; + +public sealed class CommandFatalException : Exception +{ + public string ErrorMessage { get; } + public CommandFatalException(string errorMessage) : base(errorMessage) + { + ErrorMessage = errorMessage; + } +} diff --git a/ThunderstoreCLI/Utils/ModDependencyTree.cs b/ThunderstoreCLI/Utils/ModDependencyTree.cs new file mode 100644 index 0000000..ed32a69 --- /dev/null +++ b/ThunderstoreCLI/Utils/ModDependencyTree.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Models; + +namespace ThunderstoreCLI.Utils; + +public static class ModDependencyTree +{ + public static IEnumerable Generate(Config config, HttpClient http, PackageManifestV1 root) + { + HashSet alreadyGottenPackages = new(); + foreach (var dependency in root.Dependencies!) + { + var depParts = dependency.Split('-'); + var depRequest = http.Send(config.Api.GetPackageMetadata(depParts[0], depParts[1])); + depRequest.EnsureSuccessStatusCode(); + var depData = PackageData.Deserialize(depRequest.Content.ReadAsStream()); + foreach (var package in GenerateInternal(config, http, depData!, package => alreadyGottenPackages.Contains(package.Fullname!))) + { + // this can happen on cyclical references, oh well + if (alreadyGottenPackages.Contains(package.Fullname!)) + continue; + + alreadyGottenPackages.Add(package.Fullname!); + yield return package; + } + } + } + private static IEnumerable GenerateInternal(Config config, HttpClient http, PackageData root, Predicate alreadyGotten) + { + if (alreadyGotten(root)) + yield break; + + foreach (var dependency in root.LatestVersion!.Dependencies!) + { + var depParts = dependency.Split('-'); + var depRequest = http.Send(config.Api.GetPackageMetadata(depParts[0], depParts[1])); + depRequest.EnsureSuccessStatusCode(); + var depData = PackageData.Deserialize(depRequest.Content.ReadAsStream()); + foreach (var package in GenerateInternal(config, http, depData!, alreadyGotten)) + { + yield return package; + } + } + yield return root; + } +} diff --git a/tcli-bepinex-installer/Cargo.lock b/tcli-bepinex-installer/Cargo.lock index 21ec841..fbe02c6 100644 --- a/tcli-bepinex-installer/Cargo.lock +++ b/tcli-bepinex-installer/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.57" +version = "1.0.65" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc" +checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602" [[package]] name = "atty" @@ -37,12 +37,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "autocfg" -version = "1.1.0" -source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - [[package]] name = "base64ct" version = "1.0.1" @@ -57,9 +51,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "block-buffer" -version = "0.10.2" +version = "0.10.3" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" dependencies = [ "generic-array", ] @@ -117,26 +111,24 @@ dependencies = [ [[package]] name = "clap" -version = "3.1.18" +version = "4.0.9" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b" +checksum = "30607dd93c420c6f1f80b544be522a0238a7db35e6a12968d28910983fee0df0" dependencies = [ "atty", "bitflags", "clap_derive", "clap_lex", - "indexmap", - "lazy_static", + "once_cell", "strsim", "termcolor", - "textwrap", ] [[package]] name = "clap_derive" -version = "3.1.18" +version = "4.0.9" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "25320346e922cffe59c0bbc5410c8d8784509efb321488971081313cb1e1a33c" +checksum = "a4a307492e1a34939f79d3b6b9650bd2b971513cd775436bf2b78defeb5af00b" dependencies = [ "heck", "proc-macro-error", @@ -147,9 +139,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.2.0" +version = "0.3.0" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213" +checksum = "0d4198f73e42b4936b35b5bb248d81d2b595ecb170da0bac7655c54eedfa8da8" dependencies = [ "os_str_bytes", ] @@ -162,9 +154,9 @@ checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" [[package]] name = "cpufeatures" -version = "0.2.2" +version = "0.2.5" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" +checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320" dependencies = [ "libc", ] @@ -180,19 +172,18 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.8" +version = "0.8.12" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" +checksum = "edbafec5fa1f196ca66527c1b12c2ec4745ca14b50f1ad8f9f6f720b55d11fac" dependencies = [ "cfg-if", - "lazy_static", ] [[package]] name = "crypto-common" -version = "0.1.3" +version = "0.1.6" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", @@ -200,9 +191,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.3" +version = "0.10.5" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c" dependencies = [ "block-buffer", "crypto-common", @@ -211,32 +202,24 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.23" +version = "1.0.24" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "b39522e96686d38f4bc984b9198e3a0613264abaebaff2c5c918bfa6b6da09af" +checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" dependencies = [ - "cfg-if", "crc32fast", - "libc", "miniz_oxide", ] [[package]] name = "generic-array" -version = "0.14.5" +version = "0.14.6" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" dependencies = [ "typenum", "version_check", ] -[[package]] -name = "hashbrown" -version = "0.11.2" -source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" - [[package]] name = "heck" version = "0.4.0" @@ -261,48 +244,32 @@ dependencies = [ "digest", ] -[[package]] -name = "indexmap" -version = "1.8.1" -source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee" -dependencies = [ - "autocfg", - "hashbrown", -] - [[package]] name = "itoa" -version = "1.0.1" +version = "1.0.3" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" +checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" [[package]] name = "jobserver" -version = "0.1.24" +version = "0.1.25" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" +checksum = "068b1ee6743e4d11fb9c6a1e6064b3693a1b600e7f5f5988047d98b3dc9fb90b" dependencies = [ "libc", ] -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - [[package]] name = "libc" -version = "0.2.125" +version = "0.2.134" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "5916d2ae698f6de9bfb891ad7a8d65c09d232dc58cc4ac433c7da3b2fd84bc2b" +checksum = "329c933548736bc49fd575ee68c89e8be4d260064184389a5b77517cddd99ffb" [[package]] name = "miniz_oxide" -version = "0.5.1" +version = "0.5.4" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "d2b29bd4bc3f33391105ebee3589c19197c4271e3e5a9ec9bfe8127eeff8f082" +checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34" dependencies = [ "adler", ] @@ -316,6 +283,12 @@ dependencies = [ "libc", ] +[[package]] +name = "once_cell" +version = "1.15.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" + [[package]] name = "opaque-debug" version = "0.3.0" @@ -324,9 +297,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "os_str_bytes" -version = "6.0.1" +version = "6.3.0" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "029d8d0b2f198229de29dca79676f2738ff952edf3fde542eb8bf94d8c21b435" +checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" [[package]] name = "password-hash" @@ -383,48 +356,48 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.38" +version = "1.0.46" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "9027b48e9d4c9175fa2218adf3557f91c1137021739951d4932f5f8268ac48aa" +checksum = "94e2ef8dbfc347b10c094890f778ee2e36ca9bb4262e86dc99cd217e35f3470b" dependencies = [ - "unicode-xid", + "unicode-ident", ] [[package]] name = "quote" -version = "1.0.18" +version = "1.0.21" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" dependencies = [ "proc-macro2", ] [[package]] name = "rand_core" -version = "0.6.3" +version = "0.6.4" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "ryu" -version = "1.0.9" +version = "1.0.11" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" +checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" [[package]] name = "serde" -version = "1.0.137" +version = "1.0.145" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1" +checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.137" +version = "1.0.145" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be" +checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" dependencies = [ "proc-macro2", "quote", @@ -433,9 +406,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.81" +version = "1.0.85" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" +checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" dependencies = [ "itoa", "ryu", @@ -444,9 +417,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.1" +version = "0.10.5" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "c77f4e7f65455545c2153c1253d25056825e77ee2533f0e41deb65a93a34852f" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" dependencies = [ "cfg-if", "cpufeatures", @@ -455,9 +428,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.2" +version = "0.10.6" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" dependencies = [ "cfg-if", "cpufeatures", @@ -478,13 +451,13 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.94" +version = "1.0.101" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "a07e33e919ebcd69113d5be0e4d70c5707004ff45188910106854f38b960df4a" +checksum = "e90cde112c4b9690b8cbe810cba9ddd8bc1d7472e2cae317b69e9438c1cba7d2" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", ] [[package]] @@ -508,26 +481,20 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "textwrap" -version = "0.15.0" -source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" - [[package]] name = "thiserror" -version = "1.0.31" +version = "1.0.37" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.31" +version = "1.0.37" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" dependencies = [ "proc-macro2", "quote", @@ -536,9 +503,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.9" +version = "0.3.14" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "c2702e08a7a860f005826c6815dcac101b19b5eb330c27fe4a5928fec1d20ddd" +checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b" dependencies = [ "itoa", "libc", @@ -559,10 +526,10 @@ source = "registry+/~https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" [[package]] -name = "unicode-xid" -version = "0.2.3" +name = "unicode-ident" +version = "1.0.4" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" +checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" [[package]] name = "version_check" diff --git a/tcli-bepinex-installer/Cargo.toml b/tcli-bepinex-installer/Cargo.toml index 8cd5242..93f0acb 100644 --- a/tcli-bepinex-installer/Cargo.toml +++ b/tcli-bepinex-installer/Cargo.toml @@ -6,14 +6,14 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -anyhow = "1.0.57" +anyhow = "1.0.65" zip = "0.6.2" -thiserror = "1.0.31" -serde_json = "1.0.81" -serde = { version = "1.0.137", features = ["derive"] } +thiserror = "1.0.37" +serde_json = "1.0.85" +serde = { version = "1.0.145", features = ["derive"] } [dependencies.clap] -version = "3.1.18" +version = "4.0.9" features = ["derive", "cargo"] [profile.release] diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs index f4eacb3..5675580 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -13,7 +13,7 @@ use zip::ZipArchive; #[derive(Parser)] #[clap(author, version, about)] -struct Args { +struct ClapArgs { #[clap(subcommand)] pub command: Commands, } @@ -24,6 +24,8 @@ enum Commands { game_directory: PathBuf, bepinex_directory: PathBuf, zip_path: PathBuf, + #[arg(long)] + namespace_backup: Option, }, Uninstall { game_directory: PathBuf, @@ -42,7 +44,7 @@ pub enum Error { InvalidManifest, #[error("Malformed zip")] MalformedZip, - #[error("Manifest does not contain a namespace, which is required for mod installs")] + #[error("Manifest does not contain a namespace and no backup was given, namespaces are required for mod installs")] MissingNamespace, #[error("Mod name is invalid (eg doesn't use a - between namespace and name)")] InvalidModName, @@ -62,13 +64,14 @@ struct ManifestV1 { } fn main() -> Result<()> { - let args = Args::parse(); + let args = ClapArgs::parse(); match args.command { Commands::Install { game_directory, bepinex_directory, zip_path, + namespace_backup, } => { if !game_directory.exists() { bail!(Error::PathDoesNotExist(game_directory)); @@ -79,7 +82,7 @@ fn main() -> Result<()> { if !zip_path.exists() { bail!(Error::PathDoesNotExist(zip_path)); } - install(game_directory, bepinex_directory, zip_path) + install(game_directory, bepinex_directory, zip_path, namespace_backup) } Commands::Uninstall { game_directory, @@ -97,7 +100,7 @@ fn main() -> Result<()> { } } -fn install(game_dir: PathBuf, bep_dir: PathBuf, zip_path: PathBuf) -> Result<()> { +fn install(game_dir: PathBuf, bep_dir: PathBuf, zip_path: PathBuf, namespace_backup: Option) -> Result<()> { let mut zip = ZipArchive::new(std::fs::File::open(zip_path)?)?; if !zip.file_names().any(|name| name == "manifest.json") { @@ -112,7 +115,7 @@ fn install(game_dir: PathBuf, bep_dir: PathBuf, zip_path: PathBuf) -> Result<()> if manifest.name.starts_with("BepInExPack") { install_bepinex(game_dir, bep_dir, zip) } else { - install_mod(bep_dir, zip, manifest) + install_mod(bep_dir, zip, manifest, namespace_backup) } } @@ -169,12 +172,13 @@ fn install_mod( bep_dir: PathBuf, mut zip: ZipArchive, manifest: ManifestV1, + namespace_backup: Option, ) -> Result<()> { let write_opts = OpenOptions::new().write(true).create(true).clone(); let full_name = format!( "{}-{}", - manifest.namespace.ok_or(Error::MissingNamespace)?, + manifest.namespace.or(namespace_backup).ok_or(Error::MissingNamespace)?, manifest.name ); From cdffd300bc59441217eabf9a1467824794a7317c Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Tue, 11 Oct 2022 03:04:07 -0500 Subject: [PATCH 07/25] Add support for specifying mod version to install --- ThunderstoreCLI.Tests/Utils/Spinner.cs | 6 +- ThunderstoreCLI/API/ApiHelper.cs | 8 ++ ThunderstoreCLI/Commands/InstallCommand.cs | 90 +++++++++++-------- ThunderstoreCLI/Commands/PublishCommand.cs | 2 +- .../Configuration/CLIParameterConfig.cs | 2 - ThunderstoreCLI/Configuration/Config.cs | 2 - ThunderstoreCLI/Game/GameDefinition.cs | 55 +++++++----- ThunderstoreCLI/Game/ModProfile.cs | 12 ++- ThunderstoreCLI/Options.cs | 3 - ThunderstoreCLI/PackageManifestV1.cs | 15 ++++ ThunderstoreCLI/Utils/RequestBuilder.cs | 4 + ThunderstoreCLI/Utils/Spinner.cs | 18 +++- 12 files changed, 139 insertions(+), 78 deletions(-) diff --git a/ThunderstoreCLI.Tests/Utils/Spinner.cs b/ThunderstoreCLI.Tests/Utils/Spinner.cs index e8258ce..fae60e2 100644 --- a/ThunderstoreCLI.Tests/Utils/Spinner.cs +++ b/ThunderstoreCLI.Tests/Utils/Spinner.cs @@ -31,7 +31,7 @@ public async Task WhenTaskFails_ThrowsSpinnerException() CreateTask(false) }); - await Assert.ThrowsAsync(async () => await spinner.Start()); + await Assert.ThrowsAsync(async () => await spinner.Spin()); } [Fact] @@ -41,7 +41,7 @@ public async Task WhenReceivesSingleTask_ItJustWorks() CreateTask(true) }); - await spinner.Start(); + await spinner.Spin(); } [Fact] @@ -53,6 +53,6 @@ public async Task WhenReceivesMultipleTasks_ItJustWorks() CreateTask(true) }); - await spinner.Start(); + await spinner.Spin(); } } diff --git a/ThunderstoreCLI/API/ApiHelper.cs b/ThunderstoreCLI/API/ApiHelper.cs index 7dc3ef5..214d471 100644 --- a/ThunderstoreCLI/API/ApiHelper.cs +++ b/ThunderstoreCLI/API/ApiHelper.cs @@ -81,6 +81,14 @@ public HttpRequestMessage GetPackageMetadata(string author, string name) .GetRequest(); } + public HttpRequestMessage GetPackageVersionMetadata(string author, string name, string version) + { + return BaseRequestBuilder + .StartNew() + .WithEndpoint(EXPERIMENTAL + $"package/{author}/{name}/{version}/") + .GetRequest(); + } + private static string SerializeFileData(string filePath) { return new FileData() diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index 3bc1a05..60edb09 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -21,33 +21,27 @@ public static class InstallCommand { "vrising_dedicated", HardcodedGame.VRISING_SERVER } }; - internal static readonly Regex FullPackageNameRegex = new(@"^([a-zA-Z0-9_]+)-([a-zA-Z0-9_]+)$"); + // will match either ab-cd or ab-cd-123.456.7890 + internal static readonly Regex FullPackageNameRegex = new(@"^(\w+)-(\w+)(?:|-(\d+\.\d+\.\d+))$"); public static async Task Run(Config config) { - List defs = GameDefinition.GetGameDefinitions(config.GeneralConfig.TcliConfig); + using var defCollection = GameDefintionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + var defs = defCollection.List; GameDefinition? def = defs.FirstOrDefault(x => x.Identifier == config.ModManagementConfig.GameIdentifer); if (def == null && IDToHardcoded.TryGetValue(config.ModManagementConfig.GameIdentifer!, out var hardcoded)) { def = GameDefinition.FromHardcodedIdentifier(config.GeneralConfig.TcliConfig, hardcoded); defs.Add(def); } - else + else if (def == null) { Write.ErrorExit($"Not configured for the game: {config.ModManagementConfig.GameIdentifer}"); return 1; } - ModProfile? profile; - if (config.ModManagementConfig.Global!.Value) - { - profile = def.GlobalProfile; - } - else - { - profile = def.Profiles.FirstOrDefault(x => x.Name == config.ModManagementConfig.ProfileName); - } - profile ??= new ModProfile(def, false, config.ModManagementConfig.ProfileName!, config.GeneralConfig.TcliConfig); + ModProfile? profile = def.Profiles.FirstOrDefault(x => x.Name == config.ModManagementConfig.ProfileName); + profile ??= new ModProfile(def, config.ModManagementConfig.ProfileName!, config.GeneralConfig.TcliConfig); string package = config.ModManagementConfig.Package!; @@ -67,22 +61,32 @@ public static async Task Run(Config config) throw new CommandFatalException($"Package given does not exist as a zip and is not a valid package identifier (namespace-name): {package}"); } - if (returnCode != 0) - return returnCode; + if (returnCode == 0) defCollection.Validate(); - GameDefinition.SetGameDefinitions(config.GeneralConfig.TcliConfig, defs); - - return 0; + return returnCode; } private static async Task InstallFromRepository(Config config, HttpClient http, GameDefinition game, ModProfile profile, string packageId) { var packageParts = packageId.Split('-'); - var packageResponse = await http.SendAsync(config.Api.GetPackageMetadata(packageParts[0], packageParts[1])); - packageResponse.EnsureSuccessStatusCode(); - var package = (await PackageData.DeserializeAsync(await packageResponse.Content.ReadAsStreamAsync()))!; - var tempZipPath = await DownloadTemp(http, package); - var returnCode = await InstallZip(config, http, game, profile, tempZipPath, package.Namespace); + + PackageVersionData version; + if (packageParts.Length == 3) + { + var versionResponse = await http.SendAsync(config.Api.GetPackageVersionMetadata(packageParts[0], packageParts[1], packageParts[2])); + versionResponse.EnsureSuccessStatusCode(); + version = (await PackageVersionData.DeserializeAsync(await versionResponse.Content.ReadAsStreamAsync()))!; + } + else + { + var packageResponse = await http.SendAsync(config.Api.GetPackageMetadata(packageParts[0], packageParts[1])); + packageResponse.EnsureSuccessStatusCode(); + version = (await PackageData.DeserializeAsync(await packageResponse.Content.ReadAsStreamAsync()))!.LatestVersion!; + } + + + var tempZipPath = await DownloadTemp(http, version); + var returnCode = await InstallZip(config, http, game, profile, tempZipPath, version.Namespace!); File.Delete(tempZipPath); return returnCode; } @@ -94,28 +98,42 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe var manifest = await PackageManifestV1.DeserializeAsync(manifestFile.Open()) ?? throw new CommandFatalException("Package manifest.json is invalid! Please check against https://thunderstore.io/tools/manifest-v1-validator/"); - var modsToInstall = ModDependencyTree.Generate(config, http, manifest).ToArray(); + manifest.Namespace ??= backupNamespace; - var downloadTasks = modsToInstall.Select(mod => DownloadTemp(http, mod)).ToArray(); - var spinner = new ProgressSpinner("mods downloaded", downloadTasks); - await spinner.Start(); + var dependenciesToInstall = ModDependencyTree.Generate(config, http, manifest) + .Where(dependency => !profile.InstalledModVersions.ContainsKey(dependency.Fullname!)) + .ToArray(); - foreach (var (tempZipPath, package) in downloadTasks.Select(x => x.Result).Zip(modsToInstall)) + if (dependenciesToInstall.Length > 0) { - int returnCode = RunInstaller(game, profile, tempZipPath, package.Namespace); - File.Delete(tempZipPath); - if (returnCode != 0) - return returnCode; + var downloadTasks = dependenciesToInstall.Select(mod => DownloadTemp(http, mod.LatestVersion!)).ToArray(); + + var spinner = new ProgressSpinner("mods downloaded", downloadTasks); + await spinner.Spin(); + + foreach (var (tempZipPath, package) in downloadTasks.Select(x => x.Result).Zip(dependenciesToInstall)) + { + int returnCode = RunInstaller(game, profile, tempZipPath, package.Namespace); + File.Delete(tempZipPath); + if (returnCode != 0) + return returnCode; + profile.InstalledModVersions[package.Fullname!] = new PackageManifestV1(package.LatestVersion!); + } } - return RunInstaller(game, profile, zipPath, backupNamespace); + var exitCode = RunInstaller(game, profile, zipPath, backupNamespace); + if (exitCode == 0) + { + profile.InstalledModVersions[$"{manifest.Namespace ?? backupNamespace}-{manifest.Name}"] = manifest; + } + return exitCode; } - private static async Task DownloadTemp(HttpClient http, PackageData package) + private static async Task DownloadTemp(HttpClient http, PackageVersionData version) { string path = Path.GetTempFileName(); await using var file = File.OpenWrite(path); - using var response = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, package.LatestVersion!.DownloadUrl!)); + using var response = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, version.DownloadUrl!)); response.EnsureSuccessStatusCode(); var zipStream = await response.Content.ReadAsStreamAsync(); await zipStream.CopyToAsync(file); @@ -125,7 +143,7 @@ private static async Task DownloadTemp(HttpClient http, PackageData pack private static int RunInstaller(GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace) { string installerName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tcli-bepinex-installer.exe" : "tcli-bepinex-installer"; - var bepinexInstallerPath = Path.Combine(Path.GetDirectoryName(typeof(InstallCommand).Assembly.Location)!, installerName); + var bepinexInstallerPath = Path.Combine(AppContext.BaseDirectory, installerName); ProcessStartInfo installerInfo = new(bepinexInstallerPath) { diff --git a/ThunderstoreCLI/Commands/PublishCommand.cs b/ThunderstoreCLI/Commands/PublishCommand.cs index ed00940..559e530 100644 --- a/ThunderstoreCLI/Commands/PublishCommand.cs +++ b/ThunderstoreCLI/Commands/PublishCommand.cs @@ -92,7 +92,7 @@ public static int PublishFile(Config config, string filepath) try { var spinner = new ProgressSpinner("chunks uploaded", uploadTasks); - spinner.Start().GetAwaiter().GetResult(); + spinner.Spin().GetAwaiter().GetResult(); } catch (SpinnerException) { diff --git a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs index c9626bb..6a29d97 100644 --- a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs +++ b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs @@ -86,9 +86,7 @@ public ModManagementCommandConfig(ModManagementOptions options) : base(options) { return new ModManagementConfig() { - //ManagerIdentifier = options.ManagerId GameIdentifer = options.GameName, - Global = options.Global, ProfileName = options.Profile, Package = options.Package }; diff --git a/ThunderstoreCLI/Configuration/Config.cs b/ThunderstoreCLI/Configuration/Config.cs index 9dba97c..577ae34 100644 --- a/ThunderstoreCLI/Configuration/Config.cs +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -216,8 +216,6 @@ public class AuthConfig public class ModManagementConfig { public string? GameIdentifer { get; set; } - //public string? ManagerIdentifier { get; set; } public string? Package { get; set; } - public bool? Global { get; set; } public string? ProfileName { get; set; } } diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs index 89e550d..1d42e83 100644 --- a/ThunderstoreCLI/Game/GameDefinition.cs +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -1,5 +1,4 @@ -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; +using System.Collections; using ThunderstoreCLI.Models; using ThunderstoreCLI.Utils; @@ -7,12 +6,10 @@ namespace ThunderstoreCLI.Game; public class GameDefinition : BaseJson { - private const string FILE_NAME = "GameDefintions.json"; - public string Identifier { get; } - public string Name { get; } - public string InstallDirectory { get; private set; } + public string Identifier { get; set; } + public string Name { get; set; } + public string InstallDirectory { get; set; } public List Profiles { get; private set; } = new(); - public ModProfile GlobalProfile { get; } #pragma warning disable CS8618 private GameDefinition() { } @@ -23,16 +20,6 @@ internal GameDefinition(string id, string name, string installDirectory, string Identifier = id; Name = name; InstallDirectory = installDirectory; - GlobalProfile = new ModProfile(this, true, "Global", tcliDirectory); - } - - internal static List GetGameDefinitions(string tcliDirectory) - { - var filename = Path.Combine(tcliDirectory, FILE_NAME); - if (File.Exists(filename)) - return DeserializeList(File.ReadAllText(filename)) ?? new(); - else - return new(); } internal static GameDefinition FromHardcodedIdentifier(string tcliDir, HardcodedGame game) @@ -48,7 +35,7 @@ internal static GameDefinition FromHardcodedIdentifier(string tcliDir, Hardcoded internal static GameDefinition FromSteamId(string tcliDir, uint steamId, string id, string name) { - return new GameDefinition(id, name, SteamUtils.FindInstallDirectory(steamId), tcliDir); + return new GameDefinition(id, name, SteamUtils.FindInstallDirectory(steamId)!, tcliDir); } internal static GameDefinition FromSteamId(string tcliDir, uint steamId, string subdirectory, string id, string name) @@ -57,10 +44,38 @@ internal static GameDefinition FromSteamId(string tcliDir, uint steamId, string gameDef.InstallDirectory = Path.Combine(gameDef.InstallDirectory, subdirectory); return gameDef; } +} + +public sealed class GameDefintionCollection : IEnumerable, IDisposable +{ + private const string FILE_NAME = "GameDefintions.json"; + + private readonly string tcliDirectory; + private bool shouldWrite = true; + public List List { get; } + + internal static GameDefintionCollection FromDirectory(string tcliDirectory) => new(tcliDirectory); + + private GameDefintionCollection(string tcliDir) + { + tcliDirectory = tcliDir; + var filename = Path.Combine(tcliDirectory, FILE_NAME); + if (File.Exists(filename)) + List = GameDefinition.DeserializeList(File.ReadAllText(filename)) ?? new(); + else + List = new(); + } + + public void Validate() => shouldWrite = true; + + public IEnumerator GetEnumerator() => List.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => List.GetEnumerator(); - internal static void SetGameDefinitions(string tcliDirectory, List list) + public void Dispose() { - File.WriteAllText(Path.Combine(tcliDirectory, FILE_NAME), list.SerializeList(BaseJson.IndentedSettings)); + if (!shouldWrite) return; + File.WriteAllText(Path.Combine(tcliDirectory, FILE_NAME), List.SerializeList(BaseJson.IndentedSettings)); + shouldWrite = false; } } diff --git a/ThunderstoreCLI/Game/ModProfile.cs b/ThunderstoreCLI/Game/ModProfile.cs index a86f8c6..5dc1d70 100644 --- a/ThunderstoreCLI/Game/ModProfile.cs +++ b/ThunderstoreCLI/Game/ModProfile.cs @@ -6,20 +6,18 @@ namespace ThunderstoreCLI.Game; public class ModProfile : BaseJson { - public bool IsGlobal { get; } - public string Name { get; } - public string ProfileDirectory { get; } - public List InstalledMods { get; } + public string Name { get; set; } + public string ProfileDirectory { get; set; } + public Dictionary InstalledModVersions { get; } = new(); #pragma warning disable CS8618 private ModProfile() { } #pragma warning restore CS8618 - internal ModProfile(GameDefinition gameDef, bool global, string name, string tcliDirectory) + internal ModProfile(GameDefinition gameDef, string name, string tcliDirectory) { - IsGlobal = global; Name = name; - InstalledMods = new(); + InstalledModVersions = new(); var directory = Path.Combine(tcliDirectory, "Profiles", gameDef.Identifier, name); if (!Directory.Exists(directory)) diff --git a/ThunderstoreCLI/Options.cs b/ThunderstoreCLI/Options.cs index f2e9fb9..d966c92 100644 --- a/ThunderstoreCLI/Options.cs +++ b/ThunderstoreCLI/Options.cs @@ -158,9 +158,6 @@ public abstract class ModManagementOptions : BaseOptions [Option(HelpText = "Profile to install to", Default = "Default")] public string? Profile { get; set; } - [Option(HelpText = "Set to install mods globally instead of into a profile", Default = false)] - public bool Global { get; set; } - protected enum CommandInner { Install, diff --git a/ThunderstoreCLI/PackageManifestV1.cs b/ThunderstoreCLI/PackageManifestV1.cs index 236cb29..6b1fc6c 100644 --- a/ThunderstoreCLI/PackageManifestV1.cs +++ b/ThunderstoreCLI/PackageManifestV1.cs @@ -22,4 +22,19 @@ public class PackageManifestV1 : BaseJson [JsonProperty("website_url")] public string? WebsiteUrl { get; set; } + + private string? fullName; + public string FullName => fullName ??= $"{Namespace}-{Name}"; + + public PackageManifestV1() { } + + public PackageManifestV1(PackageVersionData version) + { + Namespace = version.Namespace; + Name = version.Name; + Description = version.Description; + VersionNumber = version.VersionNumber; + Dependencies = version.Dependencies?.ToArray() ?? Array.Empty(); + WebsiteUrl = version.WebsiteUrl; + } } diff --git a/ThunderstoreCLI/Utils/RequestBuilder.cs b/ThunderstoreCLI/Utils/RequestBuilder.cs index 18cd77b..1fcf8d7 100644 --- a/ThunderstoreCLI/Utils/RequestBuilder.cs +++ b/ThunderstoreCLI/Utils/RequestBuilder.cs @@ -40,6 +40,10 @@ public HttpRequestMessage GetRequest() public RequestBuilder WithEndpoint(string endpoint) { + if (!endpoint.EndsWith('/')) + { + endpoint += '/'; + } builder.Path = endpoint; return this; } diff --git a/ThunderstoreCLI/Utils/Spinner.cs b/ThunderstoreCLI/Utils/Spinner.cs index 3031e64..da47afc 100644 --- a/ThunderstoreCLI/Utils/Spinner.cs +++ b/ThunderstoreCLI/Utils/Spinner.cs @@ -9,8 +9,9 @@ public class ProgressSpinner private static readonly char[] _spinChars = { '|', '/', '-', '\\' }; private readonly string _label; private readonly Task[] _tasks; + private readonly int _offset; - public ProgressSpinner(string label, Task[] tasks) + public ProgressSpinner(string label, Task[] tasks, int offset = 0) { if (tasks.Length == 0) { @@ -19,9 +20,10 @@ public ProgressSpinner(string label, Task[] tasks) _label = label; _tasks = tasks; + _offset = offset; } - public async Task Start() + public async Task Spin() { // Cursor operations are not always available e.g. in GitHub Actions environment. // Done up here to minimize exception usage (throws and catches are expensive and all) @@ -37,6 +39,14 @@ public async Task Start() canUseCursor = false; } + if (!canUseCursor && _offset != 0) + { + for (int i = 1; i <= _offset; i++) + { + Console.Write(Green($"{0}/{_tasks.Length + _offset} {_label}")); + } + } + while (true) { IEnumerable faultedTasks; @@ -52,13 +62,13 @@ public async Task Start() { var spinner = completed == _tasks.Length ? '✓' : _spinChars[_spinIndex++ % _spinChars.Length]; Console.SetCursorPosition(0, Console.CursorTop); - Console.Write(Green($"{completed}/{_tasks.Length} {_label} {spinner}")); + Console.Write(Green($"{completed + _offset}/{_tasks.Length + _offset} {_label} {spinner}")); } else { if (completed > _lastSeenCompleted) { - Write.Success($"{completed}/{_tasks.Length} {_label}"); + Write.Success($"{completed + _offset}/{_tasks.Length + _offset} {_label}"); _lastSeenCompleted = completed; } } From e7154411ce4291ce2fa7f48b57b507f3366513d2 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Tue, 11 Oct 2022 03:04:25 -0500 Subject: [PATCH 08/25] Add additional Steam searching for Windows/macOS --- ThunderstoreCLI/Utils/SteamUtils.cs | 48 +++++++++++++++++++---------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/ThunderstoreCLI/Utils/SteamUtils.cs b/ThunderstoreCLI/Utils/SteamUtils.cs index 83ddf84..f4386dc 100644 --- a/ThunderstoreCLI/Utils/SteamUtils.cs +++ b/ThunderstoreCLI/Utils/SteamUtils.cs @@ -1,13 +1,20 @@ using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.AccessControl; using System.Text.RegularExpressions; +using Microsoft.Win32; namespace ThunderstoreCLI.Utils; public static class SteamUtils { - public static string FindInstallDirectory(uint steamAppId) + public static string? FindInstallDirectory(uint steamAppId) { - string primarySteamApps = FindSteamAppsDirectory(); + string? primarySteamApps = FindSteamAppsDirectory(); + if (primarySteamApps == null) + { + return null; + } List libraryPaths = new() { primarySteamApps }; foreach (var file in Directory.EnumerateFiles(primarySteamApps)) { @@ -36,7 +43,7 @@ public static string FindInstallDirectory(uint steamAppId) private static readonly Regex SteamAppsPathsRegex = new(@"""path""\s+""(.+)"""); private static readonly Regex ManifestInstallLocationRegex = new(@"""installdir""\s+""(.+)"""); - public static string FindSteamAppsDirectory() + public static string? FindSteamAppsDirectory() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return FindSteamAppsDirectoryWin(); @@ -47,15 +54,24 @@ public static string FindSteamAppsDirectory() else throw new NotSupportedException("Unknown operating system"); } - private static string FindSteamAppsDirectoryWin() + + [SupportedOSPlatform("Windows")] + private static string? FindSteamAppsDirectoryWin() { - throw new NotImplementedException(); + return Registry.LocalMachine.OpenSubKey(@"Software\WOW6432Node\Valve\Steam", false)?.GetValue("InstallPath") as string; } - private static string FindSteamAppsDirectoryOsx() + + private static string? FindSteamAppsDirectoryOsx() { - throw new NotImplementedException(); + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", + "Application Support", + "Steam" + ); } - private static string FindSteamAppsDirectoryLinux() + + private static string? FindSteamAppsDirectoryLinux() { string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string[] possiblePaths = { @@ -68,17 +84,19 @@ private static string FindSteamAppsDirectoryLinux() Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".steam", "root"), Path.Combine(homeDir, ".var", "app", "com.valvesoftware.Steam", ".steam") }; - string steamPath = null!; + string? steamPath = null; foreach (var path in possiblePaths) { if (Directory.Exists(path)) { steamPath = path; - goto FoundSteam; + break; } } - throw new DirectoryNotFoundException($"Could not find Steam directory, tried these paths:\n{string.Join('\n', possiblePaths)}"); -FoundSteam: + if (steamPath == null) + { + return null; + } possiblePaths = new[] { @@ -86,17 +104,15 @@ private static string FindSteamAppsDirectoryLinux() Path.Combine(steamPath, "steam", "steamapps"), // ubuntu apparently Path.Combine(steamPath, "root", "steamapps"), // no idea }; - string steamAppsPath = null!; + string? steamAppsPath = null; foreach (var path in possiblePaths) { if (Directory.Exists(path)) { steamAppsPath = path; - goto FoundSteamApps; + break; } } - throw new DirectoryNotFoundException($"Could not find steamapps directory, tried these paths:\n{string.Join('\n', possiblePaths)}"); -FoundSteamApps: return steamAppsPath; } From 75b4fa6970b50587169e1bcfa8747cda5b342a0b Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Tue, 11 Oct 2022 03:04:38 -0500 Subject: [PATCH 09/25] Add uninstall command --- ThunderstoreCLI/Commands/UninstallCommand.cs | 89 ++++++++++++++++++++ ThunderstoreCLI/Utils/Write.cs | 4 + tcli-bepinex-installer/Cargo.lock | 32 +++---- tcli-bepinex-installer/src/main.rs | 14 +-- 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/ThunderstoreCLI/Commands/UninstallCommand.cs b/ThunderstoreCLI/Commands/UninstallCommand.cs index d3d97cb..53d575e 100644 --- a/ThunderstoreCLI/Commands/UninstallCommand.cs +++ b/ThunderstoreCLI/Commands/UninstallCommand.cs @@ -1,4 +1,8 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Utils; namespace ThunderstoreCLI.Commands; @@ -6,6 +10,91 @@ public static class UninstallCommand { public static int Run(Config config) { + using var defCollection = GameDefintionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + GameDefinition? def = defCollection.FirstOrDefault(def => def.Identifier == config.ModManagementConfig.GameIdentifer); + if (def == null) + { + throw new CommandFatalException($"No installed mods for game ${config.ModManagementConfig.GameIdentifer}"); + } + ModProfile? profile = def.Profiles.FirstOrDefault(p => p.Name == config.ModManagementConfig.ProfileName); + if (profile == null) + { + throw new CommandFatalException($"No profile with the name {config.ModManagementConfig.ProfileName}"); + } + + HashSet modsToRemove = new() { config.ModManagementConfig.Package! }; + Queue modsToSearch = new(); + modsToSearch.Enqueue(config.ModManagementConfig.Package!); + while (modsToSearch.TryDequeue(out var search)) + { + var searchWithDash = search + '-'; + foreach (var mod in profile.InstalledModVersions.Values) + { + if (mod.Dependencies!.Any(s => s.StartsWith(searchWithDash))) + { + if (modsToRemove.Add(mod.FullName)) + { + modsToSearch.Enqueue(mod.FullName); + } + } + } + } + + foreach (var mod in modsToRemove) + { + profile.InstalledModVersions.Remove(mod); + } + + Write.Line($"The following mods will be uninstalled:\n{string.Join('\n', modsToRemove)}"); + char key; + do + { + Write.NoLine("Continue? (y/n): "); + key = Console.ReadKey().KeyChar; + Write.Empty(); + } + while (key is not 'y' and not 'n'); + + if (key == 'n') return 0; + + List failedMods = new(); + foreach (var toRemove in modsToRemove) + { + string installerName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tcli-bepinex-installer.exe" : "tcli-bepinex-installer"; + var bepinexInstallerPath = Path.Combine(AppContext.BaseDirectory, installerName); + + ProcessStartInfo installerInfo = new(bepinexInstallerPath) + { + ArgumentList = + { + "uninstall", + def.InstallDirectory, + profile.ProfileDirectory, + toRemove + }, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var installerProcess = Process.Start(installerInfo)!; + installerProcess.WaitForExit(); + + Write.Light(installerProcess.StandardOutput.ReadToEnd()); + string errors = installerProcess.StandardError.ReadToEnd(); + if (!string.IsNullOrWhiteSpace(errors) || installerProcess.ExitCode != 0) + { + Write.Error(errors); + failedMods.Add(toRemove); + } + } + + if (failedMods.Count != 0) + { + throw new CommandFatalException($"The following mods failed to uninstall:\n{string.Join('\n', failedMods)}"); + } + + defCollection.Validate(); + return 0; } } diff --git a/ThunderstoreCLI/Utils/Write.cs b/ThunderstoreCLI/Utils/Write.cs index a015a2a..b682c3c 100644 --- a/ThunderstoreCLI/Utils/Write.cs +++ b/ThunderstoreCLI/Utils/Write.cs @@ -9,6 +9,7 @@ public static class Write private static void _Regular(string msg) => Console.WriteLine(msg); private static void _Success(string msg) => Console.WriteLine(Green(msg)); private static void _Warn(string msg) => Console.WriteLine(Yellow(msg)); + private static void _NoLine(string msg) => Console.Write(msg); private static void _WriteMultiline(Action write, string msg, string[] submsgs) { @@ -49,6 +50,9 @@ public static void Light(string message, params string[] submessages) /// Write regular line to stdout public static void Line(string message) => _Regular(message); + /// Write a string to stdout with no newline + public static void NoLine(string message) => _NoLine(message); + /// Write message with highlight color to stdout public static void Note(string message, params string[] submessages) { diff --git a/tcli-bepinex-installer/Cargo.lock b/tcli-bepinex-installer/Cargo.lock index fbe02c6..a0bd4d6 100644 --- a/tcli-bepinex-installer/Cargo.lock +++ b/tcli-bepinex-installer/Cargo.lock @@ -111,9 +111,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.0.9" +version = "4.0.12" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "30607dd93c420c6f1f80b544be522a0238a7db35e6a12968d28910983fee0df0" +checksum = "385007cbbed899260395a4107435fead4cad80684461b3cc78238bdcb0bad58f" dependencies = [ "atty", "bitflags", @@ -126,9 +126,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.0.9" +version = "4.0.10" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "a4a307492e1a34939f79d3b6b9650bd2b971513cd775436bf2b78defeb5af00b" +checksum = "db342ce9fda24fb191e2ed4e102055a4d381c1086a06630174cd8da8d5d917ce" dependencies = [ "heck", "proc-macro-error", @@ -246,9 +246,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.3" +version = "1.0.4" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" +checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" [[package]] name = "jobserver" @@ -261,9 +261,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.134" +version = "0.2.135" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "329c933548736bc49fd575ee68c89e8be4d260064184389a5b77517cddd99ffb" +checksum = "68783febc7782c6c5cb401fbda4de5a9898be1762314da0bb2c10ced61f18b0c" [[package]] name = "miniz_oxide" @@ -406,9 +406,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.85" +version = "1.0.86" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" +checksum = "41feea4228a6f1cd09ec7a3593a682276702cd67b5273544757dae23c096f074" dependencies = [ "itoa", "ryu", @@ -451,9 +451,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.101" +version = "1.0.102" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "e90cde112c4b9690b8cbe810cba9ddd8bc1d7472e2cae317b69e9438c1cba7d2" +checksum = "3fcd952facd492f9be3ef0d0b7032a6e442ee9b361d4acc2b1d0c4aaa5f613a1" dependencies = [ "proc-macro2", "quote", @@ -503,9 +503,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.14" +version = "0.3.15" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b" +checksum = "d634a985c4d4238ec39cacaed2e7ae552fbd3c476b552c1deac3021b7d7eaf0c" dependencies = [ "itoa", "libc", @@ -527,9 +527,9 @@ checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" [[package]] name = "unicode-ident" -version = "1.0.4" +version = "1.0.5" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" +checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" [[package]] name = "version_check" diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs index 5675580..2063f87 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -48,8 +48,6 @@ pub enum Error { MissingNamespace, #[error("Mod name is invalid (eg doesn't use a - between namespace and name)")] InvalidModName, - #[error("Mod either is not installed or is not accessable to the uninstaller. Tried directory: {0}")] - ModNotInstalled(PathBuf), } #[derive(Deserialize)] @@ -245,16 +243,8 @@ fn uninstall_bepinex(game_dir: PathBuf, bep_dir: PathBuf) -> Result<()> { fn uninstall_mod(bep_dir: PathBuf, name: String) -> Result<()> { let actual_bep = bep_dir.join("BepInEx"); - - let main_dir = actual_bep.join("plugins").join(&name); - - if !main_dir.exists() { - bail!(Error::ModNotInstalled(main_dir)); - } - - fs::remove_dir_all(main_dir)?; - - delete_dir_if_not_deleted(actual_bep.join("patchers"))?; + delete_dir_if_not_deleted(actual_bep.join("plugins").join(&name))?; + delete_dir_if_not_deleted(actual_bep.join("patchers").join(&name))?; delete_dir_if_not_deleted(actual_bep.join("monomod").join(&name))?; Ok(()) From 4cede38135171d0841321b8f324cb4adb7e7411e Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Tue, 11 Oct 2022 16:08:06 -0500 Subject: [PATCH 10/25] Add todos and logging to install/uninstall --- ThunderstoreCLI/Commands/InstallCommand.cs | 23 +++++++++++++++++--- ThunderstoreCLI/Commands/UninstallCommand.cs | 8 ++++++- ThunderstoreCLI/Game/GameDefinition.cs | 10 ++------- ThunderstoreCLI/Program.cs | 1 + 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index 60edb09..eb173c8 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -14,6 +14,7 @@ namespace ThunderstoreCLI.Commands; public static class InstallCommand { + // TODO: stop hardcoding this, ecosystem-schema (also applies to logic in GameDefintion) internal static readonly Dictionary IDToHardcoded = new() { { "ror2", HardcodedGame.ROR2 }, @@ -61,7 +62,8 @@ public static async Task Run(Config config) throw new CommandFatalException($"Package given does not exist as a zip and is not a valid package identifier (namespace-name): {package}"); } - if (returnCode == 0) defCollection.Validate(); + if (returnCode == 0) + defCollection.Validate(); return returnCode; } @@ -115,8 +117,15 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe { int returnCode = RunInstaller(game, profile, tempZipPath, package.Namespace); File.Delete(tempZipPath); - if (returnCode != 0) + if (returnCode == 0) + { + Write.Success($"Installed mod: {package.Fullname}-{package.LatestVersion!.VersionNumber}"); + } + else + { + Write.Error($"Failed to install mod: {package.Fullname}-{package.LatestVersion!.VersionNumber}"); return returnCode; + } profile.InstalledModVersions[package.Fullname!] = new PackageManifestV1(package.LatestVersion!); } } @@ -124,11 +133,17 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe var exitCode = RunInstaller(game, profile, zipPath, backupNamespace); if (exitCode == 0) { - profile.InstalledModVersions[$"{manifest.Namespace ?? backupNamespace}-{manifest.Name}"] = manifest; + profile.InstalledModVersions[manifest.FullName] = manifest; + Write.Success($"Installed mod: {manifest.FullName}-{manifest.VersionNumber}"); + } + else + { + Write.Error($"Failed to install mod: {manifest.FullName}-{manifest.VersionNumber}"); } return exitCode; } + // TODO: replace with a mod cache private static async Task DownloadTemp(HttpClient http, PackageVersionData version) { string path = Path.GetTempFileName(); @@ -140,8 +155,10 @@ private static async Task DownloadTemp(HttpClient http, PackageVersionDa return path; } + // TODO: conflict handling private static int RunInstaller(GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace) { + // TODO: how to decide which installer to run? string installerName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tcli-bepinex-installer.exe" : "tcli-bepinex-installer"; var bepinexInstallerPath = Path.Combine(AppContext.BaseDirectory, installerName); diff --git a/ThunderstoreCLI/Commands/UninstallCommand.cs b/ThunderstoreCLI/Commands/UninstallCommand.cs index 53d575e..00a069f 100644 --- a/ThunderstoreCLI/Commands/UninstallCommand.cs +++ b/ThunderstoreCLI/Commands/UninstallCommand.cs @@ -22,6 +22,11 @@ public static int Run(Config config) throw new CommandFatalException($"No profile with the name {config.ModManagementConfig.ProfileName}"); } + if (!profile.InstalledModVersions.ContainsKey(config.ModManagementConfig.Package!)) + { + throw new CommandFatalException($"The package {config.ModManagementConfig.Package} is not installed in the profile {profile.Name}"); + } + HashSet modsToRemove = new() { config.ModManagementConfig.Package! }; Queue modsToSearch = new(); modsToSearch.Enqueue(config.ModManagementConfig.Package!); @@ -55,7 +60,8 @@ public static int Run(Config config) } while (key is not 'y' and not 'n'); - if (key == 'n') return 0; + if (key == 'n') + return 0; List failedMods = new(); foreach (var toRemove in modsToRemove) diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs index 1d42e83..5650766 100644 --- a/ThunderstoreCLI/Game/GameDefinition.cs +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -37,13 +37,6 @@ internal static GameDefinition FromSteamId(string tcliDir, uint steamId, string { return new GameDefinition(id, name, SteamUtils.FindInstallDirectory(steamId)!, tcliDir); } - - internal static GameDefinition FromSteamId(string tcliDir, uint steamId, string subdirectory, string id, string name) - { - var gameDef = FromSteamId(tcliDir, steamId, id, name); - gameDef.InstallDirectory = Path.Combine(gameDef.InstallDirectory, subdirectory); - return gameDef; - } } public sealed class GameDefintionCollection : IEnumerable, IDisposable @@ -73,7 +66,8 @@ private GameDefintionCollection(string tcliDir) public void Dispose() { - if (!shouldWrite) return; + if (!shouldWrite) + return; File.WriteAllText(Path.Combine(tcliDirectory, FILE_NAME), List.SerializeList(BaseJson.IndentedSettings)); shouldWrite = false; } diff --git a/ThunderstoreCLI/Program.cs b/ThunderstoreCLI/Program.cs index c0ca4e6..67d2ba2 100644 --- a/ThunderstoreCLI/Program.cs +++ b/ThunderstoreCLI/Program.cs @@ -29,6 +29,7 @@ private static int Main(string[] args) return exitCode; } + // TODO: replace return codes with exceptions completely private static int HandleParsed(BaseOptions parsed) { parsed.Init(); From 22668b302b8fcc88d9dab58e60587456f17752d9 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Tue, 11 Oct 2022 23:12:58 -0500 Subject: [PATCH 11/25] Add back things lost in rebase, run dotnet format --- ThunderstoreCLI/Commands/InstallCommand.cs | 3 --- ThunderstoreCLI/Commands/PublishCommand.cs | 2 -- ThunderstoreCLI/Configuration/CLIParameterConfig.cs | 2 +- ThunderstoreCLI/Configuration/Config.cs | 5 +++-- ThunderstoreCLI/Configuration/EmptyConfig.cs | 2 +- ThunderstoreCLI/Configuration/IConfigProvider.cs | 2 +- ThunderstoreCLI/Game/ModProfile.cs | 1 - 7 files changed, 6 insertions(+), 11 deletions(-) diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index eb173c8..a9b3c6b 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -1,6 +1,3 @@ -using ThunderstoreCLI.Configuration; -using ThunderstoreCLI.Game; -using System.Collections.ObjectModel; using System.Diagnostics; using System.IO.Compression; using System.Runtime.InteropServices; diff --git a/ThunderstoreCLI/Commands/PublishCommand.cs b/ThunderstoreCLI/Commands/PublishCommand.cs index 559e530..5d5bed5 100644 --- a/ThunderstoreCLI/Commands/PublishCommand.cs +++ b/ThunderstoreCLI/Commands/PublishCommand.cs @@ -1,10 +1,8 @@ using System.Net; using System.Security.Cryptography; -using System.Text; using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Models; using ThunderstoreCLI.Utils; -using ThunderstoreCLI.Models; using static Crayon.Output; namespace ThunderstoreCLI.Commands; diff --git a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs index 6a29d97..f1d09c4 100644 --- a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs +++ b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs @@ -82,7 +82,7 @@ public class ModManagementCommandConfig : BaseConfig { public ModManagementCommandConfig(ModManagementOptions options) : base(options) { } - public override ModManagementConfig? GetInstallConfig() + public override ModManagementConfig? GetModManagementConfig() { return new ModManagementConfig() { diff --git a/ThunderstoreCLI/Configuration/Config.cs b/ThunderstoreCLI/Configuration/Config.cs index 577ae34..aad72f3 100644 --- a/ThunderstoreCLI/Configuration/Config.cs +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -117,8 +117,8 @@ public static Config Parse(IConfigProvider[] configProviders) var buildConfig = new BuildConfig(); var publishConfig = new PublishConfig(); var authConfig = new AuthConfig(); - var installConfig = new ModManagementConfig(); - var result = new Config(generalConfig, packageMeta, initConfig, buildConfig, publishConfig, authConfig, installConfig); + var modManagementConfig = new ModManagementConfig(); + var result = new Config(generalConfig, packageMeta, initConfig, buildConfig, publishConfig, authConfig, modManagementConfig); foreach (var provider in configProviders) { provider.Parse(result); @@ -128,6 +128,7 @@ public static Config Parse(IConfigProvider[] configProviders) Merge(buildConfig, provider.GetBuildConfig(), false); Merge(publishConfig, provider.GetPublishConfig(), false); Merge(authConfig, provider.GetAuthConfig(), false); + Merge(modManagementConfig, provider.GetModManagementConfig(), false); } return result; } diff --git a/ThunderstoreCLI/Configuration/EmptyConfig.cs b/ThunderstoreCLI/Configuration/EmptyConfig.cs index e348f6c..77a2d6f 100644 --- a/ThunderstoreCLI/Configuration/EmptyConfig.cs +++ b/ThunderstoreCLI/Configuration/EmptyConfig.cs @@ -34,7 +34,7 @@ public virtual void Parse(Config currentConfig) { } return null; } - public virtual ModManagementConfig? GetInstallConfig() + public virtual ModManagementConfig? GetModManagementConfig() { return null; } diff --git a/ThunderstoreCLI/Configuration/IConfigProvider.cs b/ThunderstoreCLI/Configuration/IConfigProvider.cs index 71ab859..9d51556 100644 --- a/ThunderstoreCLI/Configuration/IConfigProvider.cs +++ b/ThunderstoreCLI/Configuration/IConfigProvider.cs @@ -10,5 +10,5 @@ public interface IConfigProvider BuildConfig? GetBuildConfig(); PublishConfig? GetPublishConfig(); AuthConfig? GetAuthConfig(); - ModManagementConfig? GetInstallConfig(); + ModManagementConfig? GetModManagementConfig(); } diff --git a/ThunderstoreCLI/Game/ModProfile.cs b/ThunderstoreCLI/Game/ModProfile.cs index 5dc1d70..89f5869 100644 --- a/ThunderstoreCLI/Game/ModProfile.cs +++ b/ThunderstoreCLI/Game/ModProfile.cs @@ -17,7 +17,6 @@ private ModProfile() { } internal ModProfile(GameDefinition gameDef, string name, string tcliDirectory) { Name = name; - InstalledModVersions = new(); var directory = Path.Combine(tcliDirectory, "Profiles", gameDef.Identifier, name); if (!Directory.Exists(directory)) From 769cefdf2d72c9f4cb28ca467dec97dccc8a9346 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Mon, 24 Oct 2022 23:43:18 -0500 Subject: [PATCH 12/25] Implement run command for Windows and some proton --- .../ThunderstoreCLI.Tests.csproj | 5 + ThunderstoreCLI/Commands/ImportGameCommand.cs | 40 ++++++ ThunderstoreCLI/Commands/InstallCommand.cs | 22 +-- ThunderstoreCLI/Commands/RunCommand.cs | 128 ++++++++++++++++++ .../Configuration/CLIParameterConfig.cs | 27 ++++ ThunderstoreCLI/Configuration/Config.cs | 23 +++- ThunderstoreCLI/Configuration/EmptyConfig.cs | 10 ++ .../Configuration/IConfigProvider.cs | 2 + ThunderstoreCLI/Game/GameDefinition.cs | 34 ++--- ThunderstoreCLI/Models/BaseToml.cs | 7 - ThunderstoreCLI/Models/BaseYaml.cs | 22 +++ ThunderstoreCLI/Models/ISerialize.cs | 11 +- ThunderstoreCLI/Models/R2mmGameDescription.cs | 31 +++++ ThunderstoreCLI/Options.cs | 51 ++++++- ThunderstoreCLI/Program.cs | 4 +- ThunderstoreCLI/ThunderstoreCLI.csproj | 6 + ThunderstoreCLI/Utils/SteamUtils.cs | 91 +++++++++++-- ThunderstoreCLI/dsp.yml | 19 +++ tcli-bepinex-installer/src/main.rs | 40 ++++-- 19 files changed, 505 insertions(+), 68 deletions(-) create mode 100644 ThunderstoreCLI/Commands/ImportGameCommand.cs create mode 100644 ThunderstoreCLI/Commands/RunCommand.cs create mode 100644 ThunderstoreCLI/Models/BaseYaml.cs create mode 100644 ThunderstoreCLI/Models/R2mmGameDescription.cs create mode 100644 ThunderstoreCLI/dsp.yml diff --git a/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj b/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj index 4994f5b..13b2e78 100644 --- a/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj +++ b/ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj @@ -10,6 +10,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -20,6 +24,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/ThunderstoreCLI/Commands/ImportGameCommand.cs b/ThunderstoreCLI/Commands/ImportGameCommand.cs new file mode 100644 index 0000000..e8560eb --- /dev/null +++ b/ThunderstoreCLI/Commands/ImportGameCommand.cs @@ -0,0 +1,40 @@ +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; + +namespace ThunderstoreCLI.Commands; + +public static class ImportGameCommand +{ + public static int Run(Config config) + { + R2mmGameDescription? desc; + try + { + desc = R2mmGameDescription.Deserialize(File.ReadAllText(config.GameImportConfig.FilePath!)); + } + catch (Exception e) + { + throw new CommandFatalException($"Failed to read game description file: {e}"); + } + if (desc is null) + { + throw new CommandFatalException("Game description file was empty"); + } + + var def = desc.ToGameDefintion(config); + if (def == null) + { + throw new CommandFatalException("Game not installed"); + } + + using GameDefintionCollection collection = GameDefintionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + collection.List.Add(def); + collection.Validate(); + + Write.Success($"Successfully imported {def.Name} ({def.Identifier}) with install folder \"{def.InstallDirectory}\""); + + return 0; + } +} diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index a9b3c6b..ad023d5 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -11,14 +11,6 @@ namespace ThunderstoreCLI.Commands; public static class InstallCommand { - // TODO: stop hardcoding this, ecosystem-schema (also applies to logic in GameDefintion) - internal static readonly Dictionary IDToHardcoded = new() - { - { "ror2", HardcodedGame.ROR2 }, - { "vrising", HardcodedGame.VRISING }, - { "vrising_dedicated", HardcodedGame.VRISING_SERVER } - }; - // will match either ab-cd or ab-cd-123.456.7890 internal static readonly Regex FullPackageNameRegex = new(@"^(\w+)-(\w+)(?:|-(\d+\.\d+\.\d+))$"); @@ -27,12 +19,7 @@ public static async Task Run(Config config) using var defCollection = GameDefintionCollection.FromDirectory(config.GeneralConfig.TcliConfig); var defs = defCollection.List; GameDefinition? def = defs.FirstOrDefault(x => x.Identifier == config.ModManagementConfig.GameIdentifer); - if (def == null && IDToHardcoded.TryGetValue(config.ModManagementConfig.GameIdentifer!, out var hardcoded)) - { - def = GameDefinition.FromHardcodedIdentifier(config.GeneralConfig.TcliConfig, hardcoded); - defs.Add(def); - } - else if (def == null) + if (def == null) { Write.ErrorExit($"Not configured for the game: {config.ModManagementConfig.GameIdentifer}"); return 1; @@ -83,14 +70,13 @@ private static async Task InstallFromRepository(Config config, HttpClient h version = (await PackageData.DeserializeAsync(await packageResponse.Content.ReadAsStreamAsync()))!.LatestVersion!; } - var tempZipPath = await DownloadTemp(http, version); - var returnCode = await InstallZip(config, http, game, profile, tempZipPath, version.Namespace!); + var returnCode = await InstallZip(config, http, game, profile, tempZipPath, version.Namespace!, true); File.Delete(tempZipPath); return returnCode; } - private static async Task InstallZip(Config config, HttpClient http, GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace) + private static async Task InstallZip(Config config, HttpClient http, GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace, bool requiredDownload = false) { using var zip = ZipFile.OpenRead(zipPath); var manifestFile = zip.GetEntry("manifest.json") ?? throw new CommandFatalException("Package zip needs a manifest.json!"); @@ -107,7 +93,7 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe { var downloadTasks = dependenciesToInstall.Select(mod => DownloadTemp(http, mod.LatestVersion!)).ToArray(); - var spinner = new ProgressSpinner("mods downloaded", downloadTasks); + var spinner = new ProgressSpinner("mods downloaded", downloadTasks, 1); await spinner.Spin(); foreach (var (tempZipPath, package) in downloadTasks.Select(x => x.Result).Zip(dependenciesToInstall)) diff --git a/ThunderstoreCLI/Commands/RunCommand.cs b/ThunderstoreCLI/Commands/RunCommand.cs new file mode 100644 index 0000000..3033d97 --- /dev/null +++ b/ThunderstoreCLI/Commands/RunCommand.cs @@ -0,0 +1,128 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; +using ThunderstoreCLI.Utils; +using YamlDotNet.Core.Tokens; + +namespace ThunderstoreCLI.Commands; + +public static class RunCommand +{ + public static int Run(Config config) + { + GameDefintionCollection collection = GameDefintionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + var def = collection.FirstOrDefault(g => g.Identifier == config.RunGameConfig.GameName); + + if (def == null) + { + throw new CommandFatalException($"No mods installed for game {config.RunGameConfig.GameName}"); + } + + var profile = def.Profiles.FirstOrDefault(p => p.Name == config.RunGameConfig.ProfileName); + + if (profile == null) + { + throw new CommandFatalException($"No profile found with the name {config.RunGameConfig.ProfileName}"); + } + + ProcessStartInfo startInfo = new(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "tcli-bepinex-installer.exe" : "tcli-bepinex-installer") + { + ArgumentList = + { + "start-instructions", + def.InstallDirectory, + profile.ProfileDirectory + }, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var gameIsProton = SteamUtils.IsProtonGame(def.PlatformId); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + startInfo.ArgumentList.Add("--game-platform"); + startInfo.ArgumentList.Add(gameIsProton switch + { + true => "windows", + false => "linux" + }); + } + + var installerProcess = Process.Start(startInfo)!; + installerProcess.WaitForExit(); + + string errors = installerProcess.StandardError.ReadToEnd(); + if (!string.IsNullOrWhiteSpace(errors) || installerProcess.ExitCode != 0) + { + throw new CommandFatalException($"Installer failed with errors:\n{errors}"); + } + + string runArguments = ""; + List<(string key, string value)> runEnvironment = new(); + string[] wineDlls = Array.Empty(); + + string[] outputLines = installerProcess.StandardOutput.ReadToEnd().Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + foreach (var line in outputLines) + { + var firstColon = line.IndexOf(':'); + if (firstColon == -1) + { + continue; + } + var command = line[..firstColon]; + var args = line[(firstColon + 1)..]; + switch (command) + { + case "ARGUMENTS": + runArguments = args; + break; + case "WINEDLLOVERRIDE": + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + throw new NotSupportedException(); + } + wineDlls = args.Split(':'); + break; + } + } + + var steamDir = SteamUtils.FindSteamDirectory(); + if (steamDir == null) + { + throw new CommandFatalException("Couldn't find steam install directory!"); + } + string steamExeName; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + steamExeName = "steam.sh"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + steamExeName = "steam.exe"; + } + else + { + throw new NotImplementedException(); + } + + if (gameIsProton) + { + // TODO: force wine DLL overrides with registry + } + + ProcessStartInfo runSteamInfo = new(Path.Combine(steamDir, steamExeName)) + { + Arguments = $"-applaunch {def.PlatformId} {runArguments}" + }; + + Write.Note($"Starting appid {def.PlatformId} with arguments: {runArguments}"); + var steamProcess = Process.Start(runSteamInfo)!; + steamProcess.WaitForExit(); + Write.Success($"Started game with appid {def.PlatformId}"); + + return 0; + } +} diff --git a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs index f1d09c4..851e760 100644 --- a/ThunderstoreCLI/Configuration/CLIParameterConfig.cs +++ b/ThunderstoreCLI/Configuration/CLIParameterConfig.cs @@ -92,3 +92,30 @@ public ModManagementCommandConfig(ModManagementOptions options) : base(options) }; } } + +public class GameImportCommandConfig : BaseConfig +{ + public GameImportCommandConfig(GameImportOptions options) : base(options) { } + + public override GameImportConfig? GetGameImportConfig() + { + return new GameImportConfig() + { + FilePath = options.FilePath + }; + } +} + +public class RunGameCommandConfig : BaseConfig +{ + public RunGameCommandConfig(RunGameOptions options) : base(options) { } + + public override RunGameConfig? GetRunGameConfig() + { + return new RunGameConfig() + { + GameName = options.GameName, + ProfileName = options.Profile + }; + } +} diff --git a/ThunderstoreCLI/Configuration/Config.cs b/ThunderstoreCLI/Configuration/Config.cs index aad72f3..27a992b 100644 --- a/ThunderstoreCLI/Configuration/Config.cs +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -14,12 +14,14 @@ public class Config public PublishConfig PublishConfig { get; private set; } public AuthConfig AuthConfig { get; private set; } public ModManagementConfig ModManagementConfig { get; private set; } + public GameImportConfig GameImportConfig { get; private set; } + public RunGameConfig RunGameConfig { get; private set; } // ReSharper restore AutoPropertyCanBeMadeGetOnly.Local private readonly Lazy api; public ApiHelper Api => api.Value; - private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitConfig initConfig, BuildConfig buildConfig, PublishConfig publishConfig, AuthConfig authConfig, ModManagementConfig modManagementConfig) + private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitConfig initConfig, BuildConfig buildConfig, PublishConfig publishConfig, AuthConfig authConfig, ModManagementConfig modManagementConfig, GameImportConfig gameImportConfig, RunGameConfig runGameConfig) { api = new Lazy(() => new ApiHelper(this)); GeneralConfig = generalConfig; @@ -29,6 +31,8 @@ private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitCon PublishConfig = publishConfig; AuthConfig = authConfig; ModManagementConfig = modManagementConfig; + GameImportConfig = gameImportConfig; + RunGameConfig = runGameConfig; } public static Config FromCLI(IConfigProvider cliConfig) { @@ -118,7 +122,9 @@ public static Config Parse(IConfigProvider[] configProviders) var publishConfig = new PublishConfig(); var authConfig = new AuthConfig(); var modManagementConfig = new ModManagementConfig(); - var result = new Config(generalConfig, packageMeta, initConfig, buildConfig, publishConfig, authConfig, modManagementConfig); + var gameImportConfig = new GameImportConfig(); + var runGameConfig = new RunGameConfig(); + var result = new Config(generalConfig, packageMeta, initConfig, buildConfig, publishConfig, authConfig, modManagementConfig, gameImportConfig, runGameConfig); foreach (var provider in configProviders) { provider.Parse(result); @@ -129,6 +135,8 @@ public static Config Parse(IConfigProvider[] configProviders) Merge(publishConfig, provider.GetPublishConfig(), false); Merge(authConfig, provider.GetAuthConfig(), false); Merge(modManagementConfig, provider.GetModManagementConfig(), false); + Merge(gameImportConfig, provider.GetGameImportConfig(), false); + Merge(runGameConfig, provider.GetRunGameConfig(), false); } return result; } @@ -220,3 +228,14 @@ public class ModManagementConfig public string? Package { get; set; } public string? ProfileName { get; set; } } + +public class GameImportConfig +{ + public string? FilePath { get; set; } +} + +public class RunGameConfig +{ + public string? GameName { get; set; } + public string? ProfileName { get; set; } +} diff --git a/ThunderstoreCLI/Configuration/EmptyConfig.cs b/ThunderstoreCLI/Configuration/EmptyConfig.cs index 77a2d6f..c3074b5 100644 --- a/ThunderstoreCLI/Configuration/EmptyConfig.cs +++ b/ThunderstoreCLI/Configuration/EmptyConfig.cs @@ -38,4 +38,14 @@ public virtual void Parse(Config currentConfig) { } { return null; } + + public virtual GameImportConfig? GetGameImportConfig() + { + return null; + } + + public virtual RunGameConfig? GetRunGameConfig() + { + return null; + } } diff --git a/ThunderstoreCLI/Configuration/IConfigProvider.cs b/ThunderstoreCLI/Configuration/IConfigProvider.cs index 9d51556..89b2e70 100644 --- a/ThunderstoreCLI/Configuration/IConfigProvider.cs +++ b/ThunderstoreCLI/Configuration/IConfigProvider.cs @@ -11,4 +11,6 @@ public interface IConfigProvider PublishConfig? GetPublishConfig(); AuthConfig? GetAuthConfig(); ModManagementConfig? GetModManagementConfig(); + GameImportConfig? GetGameImportConfig(); + RunGameConfig? GetRunGameConfig(); } diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs index 5650766..9c88b11 100644 --- a/ThunderstoreCLI/Game/GameDefinition.cs +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -9,33 +9,35 @@ public class GameDefinition : BaseJson public string Identifier { get; set; } public string Name { get; set; } public string InstallDirectory { get; set; } + public GamePlatform Platform { get; set; } + public string PlatformId { get; set; } public List Profiles { get; private set; } = new(); #pragma warning disable CS8618 private GameDefinition() { } #pragma warning restore CS8618 - internal GameDefinition(string id, string name, string installDirectory, string tcliDirectory) + internal GameDefinition(string id, string name, string installDirectory, GamePlatform platform, string platformId, string tcliDirectory) { Identifier = id; Name = name; InstallDirectory = installDirectory; + Platform = platform; + PlatformId = platformId; } - internal static GameDefinition FromHardcodedIdentifier(string tcliDir, HardcodedGame game) + internal static GameDefinition? FromPlatformInstall(string tcliDir, GamePlatform platform, string platformId, string id, string name) { - return game switch + var gameDir = platform switch { - HardcodedGame.ROR2 => FromSteamId(tcliDir, 632360, "ror2", "Risk of Rain 2"), - HardcodedGame.VRISING => FromSteamId(tcliDir, 1604030, "vrising", "V Rising"), - HardcodedGame.VRISING_SERVER => FromSteamId(tcliDir, 1829350, "vrising_server", "V Rising Dedicated Server"), - _ => throw new ArgumentException("Invalid enum value", nameof(game)) + GamePlatform.steam => SteamUtils.FindInstallDirectory(platformId), + _ => null }; - } - - internal static GameDefinition FromSteamId(string tcliDir, uint steamId, string id, string name) - { - return new GameDefinition(id, name, SteamUtils.FindInstallDirectory(steamId)!, tcliDir); + if (gameDir == null) + { + return null; + } + return new GameDefinition(id, name, gameDir, platform, platformId, tcliDir); } } @@ -73,9 +75,9 @@ public void Dispose() } } -internal enum HardcodedGame +public enum GamePlatform { - ROR2, - VRISING, - VRISING_SERVER + steam, + egs, + other } diff --git a/ThunderstoreCLI/Models/BaseToml.cs b/ThunderstoreCLI/Models/BaseToml.cs index daf825f..b69d16c 100644 --- a/ThunderstoreCLI/Models/BaseToml.cs +++ b/ThunderstoreCLI/Models/BaseToml.cs @@ -9,11 +9,4 @@ public abstract class BaseToml<[DynamicallyAccessedMembers(DynamicallyAccessedMe public string Serialize() => TomletMain.TomlStringFrom(this); public static T? Deserialize(string toml) => TomletMain.To(toml); - - public static ValueTask DeserializeAsync(string toml) => new(Deserialize(toml)); - public static async ValueTask DeserializeAsync(Stream toml) - { - using StreamReader reader = new(toml); - return Deserialize(await reader.ReadToEndAsync()); - } } diff --git a/ThunderstoreCLI/Models/BaseYaml.cs b/ThunderstoreCLI/Models/BaseYaml.cs new file mode 100644 index 0000000..dc9e474 --- /dev/null +++ b/ThunderstoreCLI/Models/BaseYaml.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; +using YamlDotNet.Serialization; + +namespace ThunderstoreCLI.Models; + +public abstract class BaseYaml<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T> : ISerialize where T : BaseYaml +{ + public string Serialize() + { + return BaseYamlHelper.Serializer.Serialize(this); + } + public static T? Deserialize(string input) + { + return BaseYamlHelper.Deserializer.Deserialize(input); + } +} + +file static class BaseYamlHelper +{ + public static readonly Serializer Serializer = new(); + public static readonly Deserializer Deserializer = new(); +} diff --git a/ThunderstoreCLI/Models/ISerialize.cs b/ThunderstoreCLI/Models/ISerialize.cs index 8cdd635..43fe459 100644 --- a/ThunderstoreCLI/Models/ISerialize.cs +++ b/ThunderstoreCLI/Models/ISerialize.cs @@ -8,7 +8,14 @@ public interface ISerialize<[DynamicallyAccessedMembers(DynamicallyAccessedMembe public string Serialize(); #if NET7_0 public static abstract T? Deserialize(string input); - public static abstract ValueTask DeserializeAsync(string input); - public static abstract ValueTask DeserializeAsync(Stream input); + public static virtual ValueTask DeserializeAsync(string input) + { + return new(T.Deserialize(input)); + } + public static virtual async ValueTask DeserializeAsync(Stream input) + { + using StreamReader reader = new(input); + return T.Deserialize(await reader.ReadToEndAsync()); + } #endif } diff --git a/ThunderstoreCLI/Models/R2mmGameDescription.cs b/ThunderstoreCLI/Models/R2mmGameDescription.cs new file mode 100644 index 0000000..9f6996c --- /dev/null +++ b/ThunderstoreCLI/Models/R2mmGameDescription.cs @@ -0,0 +1,31 @@ +using ThunderstoreCLI.Configuration; +using ThunderstoreCLI.Game; + +namespace ThunderstoreCLI.Models; + +public class R2mmGameDescription : BaseYaml +{ + public required string uuid { get; set; } + public required string label { get; set; } + public required DescriptionMetadata meta { get; set; } + public required PlatformDistribution[] distributions { get; set; } + public required object? legacy { get; set; } + + public GameDefinition? ToGameDefintion(Config config) + { + var platform = distributions.First(p => p.platform == GamePlatform.steam); + return GameDefinition.FromPlatformInstall(config.GeneralConfig.TcliConfig, platform.platform, platform.identifier, label, meta.displayName); + } +} + +public class PlatformDistribution +{ + public required GamePlatform platform { get; set; } + public required string identifier { get; set; } +} + +public class DescriptionMetadata +{ + public required string displayName { get; set; } + public required string iconUrl { get; set; } +} diff --git a/ThunderstoreCLI/Options.cs b/ThunderstoreCLI/Options.cs index d966c92..bbcc532 100644 --- a/ThunderstoreCLI/Options.cs +++ b/ThunderstoreCLI/Options.cs @@ -147,8 +147,6 @@ public override int Execute() public abstract class ModManagementOptions : BaseOptions { - //public string? ManagerId { get; set; } - [Value(0, MetaName = "Game Name", Required = true, HelpText = "Can be any of: ror2, vrising, vrising_dedicated, vrising_builtin")] public string GameName { get; set; } = null!; @@ -199,3 +197,52 @@ public class UninstallOptions : ModManagementOptions { protected override CommandInner CommandType => CommandInner.Uninstall; } + +[Verb("import-game")] +public class GameImportOptions : BaseOptions +{ + [Value(0, MetaName = "File Path", Required = true, HelpText = "Path to game description file to import")] + public required string FilePath { get; set; } + + public override bool Validate() + { + if (!File.Exists(FilePath)) + { + Write.ErrorExit($"Could not locate game description file at {FilePath}"); + } + + return base.Validate(); + } + + public override int Execute() + { + var config = Config.FromCLI(new GameImportCommandConfig(this)); + return ImportGameCommand.Run(config); + } +} + +[Verb("run")] +public class RunGameOptions : BaseOptions +{ + [Value(0, MetaName = "Game Name", Required = true, HelpText = "Can be any of: ror2, vrising, vrising_dedicated, vrising_builtin")] + public required string GameName { get; set; } = null!; + + [Option(HelpText = "Profile to install to", Default = "Default")] + public required string Profile { get; set; } + + public override bool Validate() + { +#if NOINSTALLERS + Write.ErrorExit("Installers are not supported when installed through dotnet tool (yet) (i hate nuget)"); + return false; +#endif + + return base.Validate(); + } + + public override int Execute() + { + var config = Config.FromCLI(new RunGameCommandConfig(this)); + return RunCommand.Run(config); + } +} diff --git a/ThunderstoreCLI/Program.cs b/ThunderstoreCLI/Program.cs index 67d2ba2..17f0028 100644 --- a/ThunderstoreCLI/Program.cs +++ b/ThunderstoreCLI/Program.cs @@ -16,13 +16,15 @@ private static int Main(string[] args) #endif var updateChecker = UpdateChecker.CheckForUpdates(); - var exitCode = Parser.Default.ParseArguments(args) + var exitCode = Parser.Default.ParseArguments(args) .MapResult( (InitOptions o) => HandleParsed(o), (BuildOptions o) => HandleParsed(o), (PublishOptions o) => HandleParsed(o), (InstallOptions o) => HandleParsed(o), (UninstallOptions o) => HandleParsed(o), + (GameImportOptions o) => HandleParsed(o), + (RunGameOptions o) => HandleParsed(o), _ => 1 // failure to parse ); UpdateChecker.WriteUpdateNotification(updateChecker); diff --git a/ThunderstoreCLI/ThunderstoreCLI.csproj b/ThunderstoreCLI/ThunderstoreCLI.csproj index e22810c..ea0e9ea 100644 --- a/ThunderstoreCLI/ThunderstoreCLI.csproj +++ b/ThunderstoreCLI/ThunderstoreCLI.csproj @@ -34,7 +34,13 @@ all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/ThunderstoreCLI/Utils/SteamUtils.cs b/ThunderstoreCLI/Utils/SteamUtils.cs index f4386dc..285de79 100644 --- a/ThunderstoreCLI/Utils/SteamUtils.cs +++ b/ThunderstoreCLI/Utils/SteamUtils.cs @@ -8,7 +8,37 @@ namespace ThunderstoreCLI.Utils; public static class SteamUtils { - public static string? FindInstallDirectory(uint steamAppId) + public static string? FindInstallDirectory(string steamAppId) + { + var path = GetAcfPath(steamAppId); + if (path == null) + { + return null; + } + + var folderName = ManifestInstallLocationRegex.Match(File.ReadAllText(path)).Groups[1].Value; + + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(path)!, "common", folderName)); + } + + public static bool IsProtonGame(string steamAppId) + { + var path = GetAcfPath(steamAppId); + if (path == null) + { + throw new ArgumentException($"{steamAppId} is not installed!"); + } + + var source = PlatformOverrideSourceRegex.Match(File.ReadAllText(path)).Groups[1].Value; + return source switch + { + "" => false, + "linux" => false, + _ => true + }; + } + + private static string? GetAcfPath(string steamAppId) { string? primarySteamApps = FindSteamAppsDirectory(); if (primarySteamApps == null) @@ -29,19 +59,30 @@ public static class SteamUtils { foreach (var file in Directory.EnumerateFiles(library)) { - if (!Path.GetFileName(file).Equals(acfName, StringComparison.OrdinalIgnoreCase)) - continue; - - var folderName = ManifestInstallLocationRegex.Match(File.ReadAllText(file)).Groups[1].Value; - - return Path.GetFullPath(Path.Combine(library, "common", folderName)); + if (Path.GetFileName(file).Equals(acfName, StringComparison.OrdinalIgnoreCase)) + { + return file; + } } } - throw new FileNotFoundException($"Could not find {acfName}, tried the following paths:\n{string.Join('\n', libraryPaths)}"); + return null; } private static readonly Regex SteamAppsPathsRegex = new(@"""path""\s+""(.+)"""); private static readonly Regex ManifestInstallLocationRegex = new(@"""installdir""\s+""(.+)"""); + private static readonly Regex PlatformOverrideSourceRegex = new(@"""platform_override_source""\s+""(.+)"""); + + public static string? FindSteamDirectory() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return FindSteamDirectoryWin(); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return FindSteamDirectoryOsx(); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + return FindSteamDirectoryLinux(); + else + throw new NotSupportedException("Unknown operating system"); + } public static string? FindSteamAppsDirectory() { @@ -56,12 +97,22 @@ public static class SteamUtils } [SupportedOSPlatform("Windows")] - private static string? FindSteamAppsDirectoryWin() + private static string? FindSteamDirectoryWin() { return Registry.LocalMachine.OpenSubKey(@"Software\WOW6432Node\Valve\Steam", false)?.GetValue("InstallPath") as string; } - private static string? FindSteamAppsDirectoryOsx() + private static string? FindSteamAppsDirectoryWin() + { + var steamDir = FindSteamDirectory(); + if (steamDir == null) + { + return null; + } + return Path.Combine(steamDir, "steamapps"); + } + + private static string? FindSteamDirectoryOsx() { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), @@ -71,7 +122,17 @@ public static class SteamUtils ); } - private static string? FindSteamAppsDirectoryLinux() + private static string? FindSteamAppsDirectoryOsx() + { + var steamDir = FindSteamDirectory(); + if (steamDir == null) + { + return null; + } + return Path.Combine(steamDir, "steamapps"); + } + + private static string? FindSteamDirectoryLinux() { string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string[] possiblePaths = { @@ -93,12 +154,18 @@ public static class SteamUtils break; } } + return steamPath; + } + + private static string? FindSteamAppsDirectoryLinux() + { + var steamPath = FindSteamDirectory(); if (steamPath == null) { return null; } - possiblePaths = new[] + var possiblePaths = new[] { Path.Combine(steamPath, "steamapps"), // most distros Path.Combine(steamPath, "steam", "steamapps"), // ubuntu apparently diff --git a/ThunderstoreCLI/dsp.yml b/ThunderstoreCLI/dsp.yml new file mode 100644 index 0000000..8e96649 --- /dev/null +++ b/ThunderstoreCLI/dsp.yml @@ -0,0 +1,19 @@ +uuid: "b4ee10ce-d22c-4da3-b084-e97ced4fec85" +label: "dsp" +meta: + displayName: "Dyson Sphere Program" + iconUrl: "DysonSphereProgram.jpg" +distributions: + - platform: "steam" + identifier: "1366540" +legacy: + internalFolderName: "DysonSphereProgram" + dataFolderName: "DSPGAME_Data" + settingsIdentifier: "DysonSphereProgram" + packageIndex: "https://dsp.thunderstore.io/api/v1/package/" + exclusionsUrl: "https://raw.githubusercontent.com/ebkr/r2modmanPlus/master/modExclusions.md" + steamFolderName: "Dyson Sphere Program" + exeNames: + - "DSPGAME.exe" + gameInstancetype: "game" + gameSelectionDisplayMode: "visible" diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs index 2063f87..e3721fe 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, ffi::OsString, fs::{self, OpenOptions}, - io::{self, Read, Seek}, + io::{self, Read, Seek, Write}, path::{Path, PathBuf}, }; @@ -32,6 +32,12 @@ enum Commands { bepinex_directory: PathBuf, name: String, }, + StartInstructions { + game_directory: PathBuf, + bepinex_directory: PathBuf, + #[arg(long)] + game_platform: Option, + } } #[derive(Debug, thiserror::Error)] @@ -95,6 +101,14 @@ fn main() -> Result<()> { } uninstall(game_directory, bepinex_directory, name) } + Commands::StartInstructions { + bepinex_directory, + game_platform, + .. + } => { + output_instructions(bepinex_directory, game_platform); + Ok(()) + } } } @@ -110,7 +124,7 @@ fn install(game_dir: PathBuf, bep_dir: PathBuf, zip_path: PathBuf, namespace_bac let manifest: ManifestV1 = serde_json::from_reader(manifest_file).map_err(|_| Error::InvalidManifest)?; - if manifest.name.starts_with("BepInExPack") { + if manifest.name.starts_with("BepInEx") { install_bepinex(game_dir, bep_dir, zip) } else { install_mod(bep_dir, zip, manifest, namespace_backup) @@ -134,8 +148,7 @@ fn install_bepinex( let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); if !top_level_directory_name(&filepath) - .or(Some("".to_string())) - .unwrap() + .unwrap_or_else(|| "".to_string()) .starts_with("BepInExPack") { continue; @@ -143,10 +156,8 @@ fn install_bepinex( let dir_to_use = if filepath.ancestors().any(|part| { part.file_name() - .or(Some(&OsString::new())) - .unwrap() - .to_string_lossy() - == "BepInEx" + .unwrap_or(&OsString::new()) + .to_string_lossy() == "BepInEx" }) { &bep_dir } else { @@ -250,6 +261,19 @@ fn uninstall_mod(bep_dir: PathBuf, name: String) -> Result<()> { Ok(()) } +fn output_instructions(bep_dir: PathBuf, platform: Option) { + if platform.as_ref().map(|p| p == "windows").unwrap_or(true) { + let drive_prefix = match platform { + Some(_) => "Z:", + None => "" + }; + + println!("ARGUMENTS:--doorstop-enable true --doorstop-target {}{}", drive_prefix, bep_dir.join("BepInEx").join("core").join("BepInEx.Preloader.dll").to_string_lossy().replace('/', "\\")); + } else { + eprintln!("native linux not implmented"); + } +} + fn top_level_directory_name(path: &Path) -> Option { path.ancestors() .skip(1) From 1b1a5d5c93693fa9d8e9338b523ccac95626e94f Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Tue, 25 Oct 2022 21:13:48 -0500 Subject: [PATCH 13/25] Add automatic proton DLL override support --- ThunderstoreCLI/Commands/InstallCommand.cs | 2 - ThunderstoreCLI/Commands/RunCommand.cs | 10 +-- ThunderstoreCLI/Utils/SteamUtils.cs | 72 ++++++++++++++++++++++ ThunderstoreCLI/ror2.yml | 8 +++ tcli-bepinex-installer/src/main.rs | 1 + 5 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 ThunderstoreCLI/ror2.yml diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index ad023d5..adc97cb 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -154,7 +154,6 @@ private static int RunInstaller(GameDefinition game, ModProfile profile, string profile.ProfileDirectory, zipPath }, - RedirectStandardOutput = true, RedirectStandardError = true }; if (backupNamespace != null) @@ -166,7 +165,6 @@ private static int RunInstaller(GameDefinition game, ModProfile profile, string var installerProcess = Process.Start(installerInfo)!; installerProcess.WaitForExit(); - Write.Light(installerProcess.StandardOutput.ReadToEnd()); string errors = installerProcess.StandardError.ReadToEnd(); if (!string.IsNullOrWhiteSpace(errors)) { diff --git a/ThunderstoreCLI/Commands/RunCommand.cs b/ThunderstoreCLI/Commands/RunCommand.cs index 3033d97..71cf08b 100644 --- a/ThunderstoreCLI/Commands/RunCommand.cs +++ b/ThunderstoreCLI/Commands/RunCommand.cs @@ -61,7 +61,6 @@ public static int Run(Config config) } string runArguments = ""; - List<(string key, string value)> runEnvironment = new(); string[] wineDlls = Array.Empty(); string[] outputLines = installerProcess.StandardOutput.ReadToEnd().Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); @@ -82,7 +81,7 @@ public static int Run(Config config) case "WINEDLLOVERRIDE": if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - throw new NotSupportedException(); + break; } wineDlls = args.Split(':'); break; @@ -108,9 +107,12 @@ public static int Run(Config config) throw new NotImplementedException(); } - if (gameIsProton) + if (gameIsProton && wineDlls.Length > 0) { - // TODO: force wine DLL overrides with registry + if (!SteamUtils.ForceLoadProton(def.PlatformId, wineDlls)) + { + throw new CommandFatalException($"No compat files could be found for app id {def.PlatformId}, please run the game at least once."); + } } ProcessStartInfo runSteamInfo = new(Path.Combine(steamDir, steamExeName)) diff --git a/ThunderstoreCLI/Utils/SteamUtils.cs b/ThunderstoreCLI/Utils/SteamUtils.cs index 285de79..fe5d71e 100644 --- a/ThunderstoreCLI/Utils/SteamUtils.cs +++ b/ThunderstoreCLI/Utils/SteamUtils.cs @@ -183,4 +183,76 @@ public static bool IsProtonGame(string steamAppId) return steamAppsPath; } + + public static bool ForceLoadProton(string steamAppId, string[] dllsToEnable) + { + var path = Path.Combine(Path.GetDirectoryName(GetAcfPath(steamAppId))!, "compatdata", steamAppId, "pfx", "user.reg"); + if (!Path.Exists(path)) + { + return false; + } + + string[] lines = File.ReadAllLines(path); + + int start = -1; + for (int i = 0; i < lines.Length; i++) + { + if (lines[i].StartsWith(@"[Software\\Wine\\DllOverrides]")) + { + start = i + 2; + break; + } + } + if (start == -1) + { + return false; + } + + int end = lines.Length - 1; + for (int i = start; i < lines.Length; i++) + { + if (lines[i].Length == 0) + { + end = i; + break; + } + } + + bool written = false; + foreach (var dll in dllsToEnable) + { + string wineOverride = $@"""{dll}""=""native,builtin"""; + bool existed = false; + for (int i = start; i < end; i++) + { + if (lines[i].StartsWith($@"""{dll}""")) + { + existed = true; + if (lines[i] != wineOverride) + { + lines[i] = wineOverride; + written = true; + } + break; + } + } + + if (!existed) + { + // resizes then moves the end and all lines past it over by 1, this is basically a manual List.Insert on an array + Array.Resize(ref lines, lines.Length + 1); + lines.AsSpan()[end..^1].CopyTo(lines.AsSpan()[(end + 1)..]); + lines[end] = wineOverride; + written = true; + } + } + + if (written) + { + File.Move(path, path + ".bak", true); + File.WriteAllLines(path, lines); + } + + return true; + } } diff --git a/ThunderstoreCLI/ror2.yml b/ThunderstoreCLI/ror2.yml new file mode 100644 index 0000000..1849582 --- /dev/null +++ b/ThunderstoreCLI/ror2.yml @@ -0,0 +1,8 @@ +uuid: "a672812c-5a54-4fb2-bcb1-0bf4b9ca1781" +label: "ror2" +meta: + displayName: "Risk of Rain 2" + iconUrl: "RiskOfRain2.jpg" +distributions: + - platform: "steam" + identifier: "632360" diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs index e3721fe..fe78738 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -269,6 +269,7 @@ fn output_instructions(bep_dir: PathBuf, platform: Option) { }; println!("ARGUMENTS:--doorstop-enable true --doorstop-target {}{}", drive_prefix, bep_dir.join("BepInEx").join("core").join("BepInEx.Preloader.dll").to_string_lossy().replace('/', "\\")); + println!("WINEDLLOVERRIDE:winhttp") } else { eprintln!("native linux not implmented"); } From fa112dd27788e03cb653dad262b279b174e39747 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Tue, 25 Oct 2022 21:15:05 -0500 Subject: [PATCH 14/25] Fix BepInEx override directory behavior r2mm makes it so SomeDir/patchers/somefile.txt still maps to patchers/Namespace-Name/somefile.txt --- tcli-bepinex-installer/src/main.rs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs index fe78738..b6e6a19 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, ffi::OsString, fs::{self, OpenOptions}, - io::{self, Read, Seek, Write}, + io::{self, Read, Seek}, path::{Path, PathBuf}, }; @@ -215,14 +215,14 @@ fn install_mod( let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); - let out_path: PathBuf = if let Some(root) = top_level_directory_name(&filepath) { - if let Some(remap) = remaps.get(&root as &str) { - remap.join(remove_first_n_directories(&filepath, 1)) + let out_path: PathBuf = if let Some((root, count)) = search_for_directory(&filepath, &["plugins", "patchers", "monomod", "config"]) { + if let Some(remap) = remaps.get(root) { + remap.join(remove_first_n_directories(&filepath, count)) } else { - remaps["plugins"].join(filepath) + remaps["plugins"].join(filepath.file_name().unwrap()) } } else { - remaps["plugins"].join(filepath) + remaps["plugins"].join(filepath.file_name().unwrap()) }; let full_out_path = bep_dir.join(out_path); @@ -283,6 +283,23 @@ fn top_level_directory_name(path: &Path) -> Option { .map(|root| root.to_string_lossy().to_string()) } +fn search_for_directory<'a>(path: &Path, targets: &[&'a str]) -> Option<(&'a str, usize)> { + let mut path_parts = path + .ancestors() + .filter(|x| !x.to_string_lossy().is_empty()) + .map(|x| x.file_name().unwrap()) + .collect::>(); + path_parts.reverse(); + for (index, part) in path_parts.into_iter().enumerate() { + for target in targets { + if part.to_string_lossy() == *target { + return Some((target, index + 1)); + } + } + } + None +} + /// removes the first n directories from a path, eg a/b/c/d.txt with an n of 2 gives c/d.txt fn remove_first_n_directories(path: &Path, n: usize) -> PathBuf { PathBuf::from_iter( From a6a3af29adc8762e5c41049a95f6bbacb0dad022 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Wed, 26 Oct 2022 18:59:29 -0500 Subject: [PATCH 15/25] Fix BepInEx override directory behavior (attempt 2) --- tcli-bepinex-installer/src/main.rs | 81 ++++++++++++------------------ 1 file changed, 33 insertions(+), 48 deletions(-) diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs index b6e6a19..0cb49fe 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -148,7 +148,7 @@ fn install_bepinex( let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); if !top_level_directory_name(&filepath) - .unwrap_or_else(|| "".to_string()) + .unwrap_or("") .starts_with("BepInExPack") { continue; @@ -191,20 +191,22 @@ fn install_mod( manifest.name ); - let mut remaps: HashMap<&str, PathBuf> = HashMap::new(); + let mut remaps = HashMap::new(); remaps.insert( - "plugins", + Path::new("BepInEx").join("plugins"), Path::new("BepInEx").join("plugins").join(&full_name), ); remaps.insert( - "patchers", + Path::new("BepInEx").join("patchers"), Path::new("BepInEx").join("patchers").join(&full_name), ); remaps.insert( - "monomod", + Path::new("BepInEx").join("monomod"), Path::new("BepInEx").join("monomod").join(&full_name), ); - remaps.insert("config", Path::new("BepInEx").join("config")); + remaps.insert(Path::new("BepInEx").join("config"), Path::new("BepInEx").join("config")); + + let default_remap = &remaps[&Path::new("BepInEx").join("plugins")]; for i in 0..zip.len() { let mut file = zip.by_index(i)?; @@ -215,17 +217,20 @@ fn install_mod( let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); - let out_path: PathBuf = if let Some((root, count)) = search_for_directory(&filepath, &["plugins", "patchers", "monomod", "config"]) { - if let Some(remap) = remaps.get(root) { - remap.join(remove_first_n_directories(&filepath, count)) - } else { - remaps["plugins"].join(filepath.file_name().unwrap()) + let mut out_path = None; + 'outer: for remap in remaps.keys() { + for variant in get_path_variants(remap) { + if let Ok(p) = filepath.strip_prefix(variant) { + out_path = Some(remaps[remap].join(p)); + break 'outer; + } } - } else { - remaps["plugins"].join(filepath.file_name().unwrap()) - }; + } + if out_path.is_none() { + out_path = Some(default_remap.join(filepath.file_name().unwrap())); + } - let full_out_path = bep_dir.join(out_path); + let full_out_path = bep_dir.join(out_path.unwrap()); fs::create_dir_all(full_out_path.parent().unwrap())?; io::copy(&mut file, &mut write_opts.open(full_out_path)?)?; @@ -235,7 +240,7 @@ fn install_mod( } fn uninstall(game_dir: PathBuf, bep_dir: PathBuf, name: String) -> Result<()> { - if name.split_once('-').ok_or(Error::InvalidModName)?.1.starts_with("BepInExPack") { + if name.split_once('-').ok_or(Error::InvalidModName)?.1.starts_with("BepInEx") { uninstall_bepinex(game_dir, bep_dir) } else { uninstall_mod(bep_dir, name) @@ -275,42 +280,22 @@ fn output_instructions(bep_dir: PathBuf, platform: Option) { } } -fn top_level_directory_name(path: &Path) -> Option { - path.ancestors() - .skip(1) - .filter(|x| !x.to_string_lossy().is_empty()) - .last() - .map(|root| root.to_string_lossy().to_string()) -} - -fn search_for_directory<'a>(path: &Path, targets: &[&'a str]) -> Option<(&'a str, usize)> { - let mut path_parts = path - .ancestors() - .filter(|x| !x.to_string_lossy().is_empty()) - .map(|x| x.file_name().unwrap()) - .collect::>(); - path_parts.reverse(); - for (index, part) in path_parts.into_iter().enumerate() { - for target in targets { - if part.to_string_lossy() == *target { - return Some((target, index + 1)); - } - } - } - None +fn top_level_directory_name(path: &Path) -> Option<&str> { + path.components().next().and_then(|n| n.as_os_str().to_str()) } /// removes the first n directories from a path, eg a/b/c/d.txt with an n of 2 gives c/d.txt fn remove_first_n_directories(path: &Path, n: usize) -> PathBuf { - PathBuf::from_iter( - path.ancestors() - .collect::>() - .into_iter() - .rev() - .filter(|x| !x.to_string_lossy().is_empty()) - .skip(n) - .map(|part| part.file_name().unwrap()), - ) + PathBuf::from_iter(path.iter().skip(n)) +} + +fn get_path_variants(path: &Path) -> Vec { + let mut res = vec![path.into()]; + let components: Vec<_> = path.components().collect(); + for i in 1usize..components.len() { + res.push(PathBuf::from_iter(components.iter().skip(i))) + } + res } fn delete_file_if_not_deleted>(path: T) -> io::Result<()> { From 71225c39d88c8742fca9bd789fc8743614cdb269 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Wed, 26 Oct 2022 19:02:39 -0500 Subject: [PATCH 16/25] Update Cargo.lock --- tcli-bepinex-installer/Cargo.lock | 87 +++++++++++++++++-------------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/tcli-bepinex-installer/Cargo.lock b/tcli-bepinex-installer/Cargo.lock index a0bd4d6..3c9ee1d 100644 --- a/tcli-bepinex-installer/Cargo.lock +++ b/tcli-bepinex-installer/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.65" +version = "1.0.66" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602" +checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" [[package]] name = "atty" @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "base64ct" -version = "1.0.1" +version = "1.5.3" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "8a32fd6af2b5827bce66c29053ba0e7c42b9dcab01835835058558c10851a46b" +checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" [[package]] name = "bitflags" @@ -111,9 +111,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.0.12" +version = "4.0.18" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "385007cbbed899260395a4107435fead4cad80684461b3cc78238bdcb0bad58f" +checksum = "335867764ed2de42325fafe6d18b8af74ba97ee0c590fa016f157535b42ab04b" dependencies = [ "atty", "bitflags", @@ -126,9 +126,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.0.10" +version = "4.0.18" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "db342ce9fda24fb191e2ed4e102055a4d381c1086a06630174cd8da8d5d917ce" +checksum = "16a1b0f6422af32d5da0c58e2703320f379216ee70198241c84173a8c5ac28f3" dependencies = [ "heck", "proc-macro-error", @@ -261,9 +261,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.135" +version = "0.2.137" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "68783febc7782c6c5cb401fbda4de5a9898be1762314da0bb2c10ced61f18b0c" +checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" [[package]] name = "miniz_oxide" @@ -303,9 +303,9 @@ checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" [[package]] name = "password-hash" -version = "0.3.2" +version = "0.4.2" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "1d791538a6dcc1e7cb7fe6f6b58aca40e7f79403c45b2bc274008b5e647af1d8" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" dependencies = [ "base64ct", "rand_core", @@ -314,9 +314,9 @@ dependencies = [ [[package]] name = "pbkdf2" -version = "0.10.1" +version = "0.11.0" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "271779f35b581956db91a3e55737327a03aa051e90b1c47aeb189508533adfd7" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest", "hmac", @@ -326,9 +326,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.25" +version = "0.3.26" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" [[package]] name = "proc-macro-error" @@ -356,9 +356,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.46" +version = "1.0.47" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "94e2ef8dbfc347b10c094890f778ee2e36ca9bb4262e86dc99cd217e35f3470b" +checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725" dependencies = [ "unicode-ident", ] @@ -386,18 +386,18 @@ checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" [[package]] name = "serde" -version = "1.0.145" +version = "1.0.147" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" +checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.145" +version = "1.0.147" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" +checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" dependencies = [ "proc-macro2", "quote", @@ -406,9 +406,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.86" +version = "1.0.87" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "41feea4228a6f1cd09ec7a3593a682276702cd67b5273544757dae23c096f074" +checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" dependencies = [ "itoa", "ryu", @@ -451,9 +451,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.102" +version = "1.0.103" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "3fcd952facd492f9be3ef0d0b7032a6e442ee9b361d4acc2b1d0c4aaa5f613a1" +checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d" dependencies = [ "proc-macro2", "quote", @@ -503,21 +503,32 @@ dependencies = [ [[package]] name = "time" -version = "0.3.15" +version = "0.3.16" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "d634a985c4d4238ec39cacaed2e7ae552fbd3c476b552c1deac3021b7d7eaf0c" +checksum = "0fab5c8b9980850e06d92ddbe3ab839c062c801f3927c0fb8abd6fc8e918fbca" dependencies = [ "itoa", "libc", "num_threads", + "serde", + "time-core", "time-macros", ] +[[package]] +name = "time-core" +version = "0.1.0" +source = "registry+/~https://github.com/rust-lang/crates.io-index" +checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" + [[package]] name = "time-macros" -version = "0.2.4" +version = "0.2.5" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" +checksum = "65bb801831d812c562ae7d2bfb531f26e66e4e1f6b17307ba4149c5064710e5b" +dependencies = [ + "time-core", +] [[package]] name = "typenum" @@ -570,9 +581,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "zip" -version = "0.6.2" +version = "0.6.3" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "bf225bcf73bb52cbb496e70475c7bd7a3f769df699c0020f6c7bd9a96dcf0b8d" +checksum = "537ce7411d25e54e8ae21a7ce0b15840e7bfcff15b51d697ec3266cc76bdf080" dependencies = [ "aes", "byteorder", @@ -590,18 +601,18 @@ dependencies = [ [[package]] name = "zstd" -version = "0.10.2+zstd.1.5.2" +version = "0.11.2+zstd.1.5.2" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "5f4a6bd64f22b5e3e94b4e238669ff9f10815c27a5180108b849d24174a83847" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "4.1.6+zstd.1.5.2" +version = "5.0.2+zstd.1.5.2" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "94b61c51bb270702d6167b8ce67340d2754b088d0c091b06e593aa772c3ee9bb" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" dependencies = [ "libc", "zstd-sys", @@ -609,9 +620,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "1.6.3+zstd.1.5.2" +version = "2.0.1+zstd.1.5.2" source = "registry+/~https://github.com/rust-lang/crates.io-index" -checksum = "fc49afa5c8d634e75761feda8c592051e7eeb4683ba827211eb0d731d3402ea8" +checksum = "9fd07cbbc53846d9145dbffdf6dd09a7a0aa52be46741825f5c97bdd4f73f12b" dependencies = [ "cc", "libc", From 0d4a51ceb6025736587900986f6fad0cee5e02ae Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Wed, 26 Oct 2022 20:25:28 -0500 Subject: [PATCH 17/25] Add logging and fix CI --- ThunderstoreCLI/Commands/InstallCommand.cs | 7 ++-- ThunderstoreCLI/Commands/RunCommand.cs | 19 ++-------- ThunderstoreCLI/Commands/UninstallCommand.cs | 4 +-- ThunderstoreCLI/Utils/Spinner.cs | 16 ++------- ThunderstoreCLI/Utils/SteamUtils.cs | 38 ++++++++++++++++++++ 5 files changed, 50 insertions(+), 34 deletions(-) diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index adc97cb..f642ab4 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -57,6 +57,7 @@ private static async Task InstallFromRepository(Config config, HttpClient h var packageParts = packageId.Split('-'); PackageVersionData version; + Write.Light($"Downloading main package: {packageId}"); if (packageParts.Length == 3) { var versionResponse = await http.SendAsync(config.Api.GetPackageVersionMetadata(packageParts[0], packageParts[1], packageParts[2])); @@ -71,12 +72,12 @@ private static async Task InstallFromRepository(Config config, HttpClient h } var tempZipPath = await DownloadTemp(http, version); - var returnCode = await InstallZip(config, http, game, profile, tempZipPath, version.Namespace!, true); + var returnCode = await InstallZip(config, http, game, profile, tempZipPath, version.Namespace!); File.Delete(tempZipPath); return returnCode; } - private static async Task InstallZip(Config config, HttpClient http, GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace, bool requiredDownload = false) + private static async Task InstallZip(Config config, HttpClient http, GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace) { using var zip = ZipFile.OpenRead(zipPath); var manifestFile = zip.GetEntry("manifest.json") ?? throw new CommandFatalException("Package zip needs a manifest.json!"); @@ -93,7 +94,7 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe { var downloadTasks = dependenciesToInstall.Select(mod => DownloadTemp(http, mod.LatestVersion!)).ToArray(); - var spinner = new ProgressSpinner("mods downloaded", downloadTasks, 1); + var spinner = new ProgressSpinner("dependencies downloaded", downloadTasks); await spinner.Spin(); foreach (var (tempZipPath, package) in downloadTasks.Select(x => x.Result).Zip(dependenciesToInstall)) diff --git a/ThunderstoreCLI/Commands/RunCommand.cs b/ThunderstoreCLI/Commands/RunCommand.cs index 71cf08b..9f4d058 100644 --- a/ThunderstoreCLI/Commands/RunCommand.cs +++ b/ThunderstoreCLI/Commands/RunCommand.cs @@ -88,24 +88,11 @@ public static int Run(Config config) } } - var steamDir = SteamUtils.FindSteamDirectory(); - if (steamDir == null) + var steamExePath = SteamUtils.FindSteamExecutable(); + if (steamExePath == null) { throw new CommandFatalException("Couldn't find steam install directory!"); } - string steamExeName; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - steamExeName = "steam.sh"; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - steamExeName = "steam.exe"; - } - else - { - throw new NotImplementedException(); - } if (gameIsProton && wineDlls.Length > 0) { @@ -115,7 +102,7 @@ public static int Run(Config config) } } - ProcessStartInfo runSteamInfo = new(Path.Combine(steamDir, steamExeName)) + ProcessStartInfo runSteamInfo = new(steamExePath) { Arguments = $"-applaunch {def.PlatformId} {runArguments}" }; diff --git a/ThunderstoreCLI/Commands/UninstallCommand.cs b/ThunderstoreCLI/Commands/UninstallCommand.cs index 00a069f..016c371 100644 --- a/ThunderstoreCLI/Commands/UninstallCommand.cs +++ b/ThunderstoreCLI/Commands/UninstallCommand.cs @@ -78,14 +78,14 @@ public static int Run(Config config) profile.ProfileDirectory, toRemove }, - RedirectStandardOutput = true, RedirectStandardError = true }; var installerProcess = Process.Start(installerInfo)!; installerProcess.WaitForExit(); - Write.Light(installerProcess.StandardOutput.ReadToEnd()); + Write.Success($"Uninstalled mod: {toRemove}"); + string errors = installerProcess.StandardError.ReadToEnd(); if (!string.IsNullOrWhiteSpace(errors) || installerProcess.ExitCode != 0) { diff --git a/ThunderstoreCLI/Utils/Spinner.cs b/ThunderstoreCLI/Utils/Spinner.cs index da47afc..4360044 100644 --- a/ThunderstoreCLI/Utils/Spinner.cs +++ b/ThunderstoreCLI/Utils/Spinner.cs @@ -9,9 +9,8 @@ public class ProgressSpinner private static readonly char[] _spinChars = { '|', '/', '-', '\\' }; private readonly string _label; private readonly Task[] _tasks; - private readonly int _offset; - public ProgressSpinner(string label, Task[] tasks, int offset = 0) + public ProgressSpinner(string label, Task[] tasks) { if (tasks.Length == 0) { @@ -20,7 +19,6 @@ public ProgressSpinner(string label, Task[] tasks, int offset = 0) _label = label; _tasks = tasks; - _offset = offset; } public async Task Spin() @@ -39,14 +37,6 @@ public async Task Spin() canUseCursor = false; } - if (!canUseCursor && _offset != 0) - { - for (int i = 1; i <= _offset; i++) - { - Console.Write(Green($"{0}/{_tasks.Length + _offset} {_label}")); - } - } - while (true) { IEnumerable faultedTasks; @@ -62,13 +52,13 @@ public async Task Spin() { var spinner = completed == _tasks.Length ? '✓' : _spinChars[_spinIndex++ % _spinChars.Length]; Console.SetCursorPosition(0, Console.CursorTop); - Console.Write(Green($"{completed + _offset}/{_tasks.Length + _offset} {_label} {spinner}")); + Console.Write(Green($"{completed}/{_tasks.Length} {_label} {spinner}")); } else { if (completed > _lastSeenCompleted) { - Write.Success($"{completed + _offset}/{_tasks.Length + _offset} {_label}"); + Write.Success($"{completed}/{_tasks.Length} {_label}"); _lastSeenCompleted = completed; } } diff --git a/ThunderstoreCLI/Utils/SteamUtils.cs b/ThunderstoreCLI/Utils/SteamUtils.cs index fe5d71e..2d80644 100644 --- a/ThunderstoreCLI/Utils/SteamUtils.cs +++ b/ThunderstoreCLI/Utils/SteamUtils.cs @@ -72,6 +72,44 @@ public static bool IsProtonGame(string steamAppId) private static readonly Regex ManifestInstallLocationRegex = new(@"""installdir""\s+""(.+)"""); private static readonly Regex PlatformOverrideSourceRegex = new(@"""platform_override_source""\s+""(.+)"""); + public static string? FindSteamExecutable() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + var mainDir = FindSteamDirectory(); + if (mainDir == null) + { + return null; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Path.Combine(mainDir, "steam.exe"); + } + else + { + return Path.Combine(mainDir, "steam.sh"); + } + } + + string appDir; + string rooted = Path.Combine("/", "Applications", "Steam.app"); + string user = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", "Steam.app"); + if (Directory.Exists(user)) + { + appDir = user; + } + else if (Directory.Exists(rooted)) + { + appDir = rooted; + } + else + { + return null; + } + + return Path.Combine(appDir, "Contents", "MacOS", "steam_osx"); + } + public static string? FindSteamDirectory() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) From 991c2efe843dabbee1b111ec455b8df78d5a7b43 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Tue, 1 Nov 2022 18:15:00 -0500 Subject: [PATCH 18/25] Add installers feature flag --- ThunderstoreCLI/Options.cs | 20 -------------------- ThunderstoreCLI/Program.cs | 8 +++++++- ThunderstoreCLI/ThunderstoreCLI.csproj | 10 +++++++--- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/ThunderstoreCLI/Options.cs b/ThunderstoreCLI/Options.cs index bbcc532..d25fe8e 100644 --- a/ThunderstoreCLI/Options.cs +++ b/ThunderstoreCLI/Options.cs @@ -164,16 +164,6 @@ protected enum CommandInner protected abstract CommandInner CommandType { get; } - public override bool Validate() - { -#if NOINSTALLERS - Write.ErrorExit("Installers are not supported when installed through dotnet tool (yet) (i hate nuget)"); - return false; -#endif - - return base.Validate(); - } - public override int Execute() { var config = Config.FromCLI(new ModManagementCommandConfig(this)); @@ -230,16 +220,6 @@ public class RunGameOptions : BaseOptions [Option(HelpText = "Profile to install to", Default = "Default")] public required string Profile { get; set; } - public override bool Validate() - { -#if NOINSTALLERS - Write.ErrorExit("Installers are not supported when installed through dotnet tool (yet) (i hate nuget)"); - return false; -#endif - - return base.Validate(); - } - public override int Execute() { var config = Config.FromCLI(new RunGameCommandConfig(this)); diff --git a/ThunderstoreCLI/Program.cs b/ThunderstoreCLI/Program.cs index 17f0028..6e99f00 100644 --- a/ThunderstoreCLI/Program.cs +++ b/ThunderstoreCLI/Program.cs @@ -16,15 +16,21 @@ private static int Main(string[] args) #endif var updateChecker = UpdateChecker.CheckForUpdates(); - var exitCode = Parser.Default.ParseArguments(args) + var exitCode = Parser.Default.ParseArguments(args) .MapResult( (InitOptions o) => HandleParsed(o), (BuildOptions o) => HandleParsed(o), (PublishOptions o) => HandleParsed(o), +#if INSTALLERS (InstallOptions o) => HandleParsed(o), (UninstallOptions o) => HandleParsed(o), (GameImportOptions o) => HandleParsed(o), (RunGameOptions o) => HandleParsed(o), +#endif _ => 1 // failure to parse ); UpdateChecker.WriteUpdateNotification(updateChecker); diff --git a/ThunderstoreCLI/ThunderstoreCLI.csproj b/ThunderstoreCLI/ThunderstoreCLI.csproj index ea0e9ea..e9ab141 100644 --- a/ThunderstoreCLI/ThunderstoreCLI.csproj +++ b/ThunderstoreCLI/ThunderstoreCLI.csproj @@ -63,8 +63,12 @@ + + true + + - + --release debug @@ -77,7 +81,7 @@ - - NOINSTALLERS + + INSTALLERS From 7090e0081d3d2763891f78c455ad9d98e08da37d Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Mon, 14 Nov 2022 07:05:29 -0600 Subject: [PATCH 19/25] Various fixes for gigamod --- ThunderstoreCLI/API/ApiHelper.cs | 8 ++ ThunderstoreCLI/Commands/InstallCommand.cs | 74 ++++++------ ThunderstoreCLI/Configuration/Config.cs | 5 + ThunderstoreCLI/Game/GameDefinition.cs | 2 +- ThunderstoreCLI/Models/PackageListingV1.cs | 125 +++++++++++++++++++++ ThunderstoreCLI/Models/PublishModels.cs | 2 +- ThunderstoreCLI/PackageManifestV1.cs | 10 ++ ThunderstoreCLI/Utils/DownloadCache.cs | 53 +++++++++ ThunderstoreCLI/Utils/ModDependencyTree.cs | 93 +++++++++++---- tcli-bepinex-installer/src/main.rs | 25 ++++- 10 files changed, 332 insertions(+), 65 deletions(-) create mode 100644 ThunderstoreCLI/Models/PackageListingV1.cs create mode 100644 ThunderstoreCLI/Utils/DownloadCache.cs diff --git a/ThunderstoreCLI/API/ApiHelper.cs b/ThunderstoreCLI/API/ApiHelper.cs index 214d471..ac77097 100644 --- a/ThunderstoreCLI/API/ApiHelper.cs +++ b/ThunderstoreCLI/API/ApiHelper.cs @@ -89,6 +89,14 @@ public HttpRequestMessage GetPackageVersionMetadata(string author, string name, .GetRequest(); } + public HttpRequestMessage GetPackagesV1() + { + return BaseRequestBuilder + .StartNew() + .WithEndpoint(V1 + "package/") + .GetRequest(); + } + private static string SerializeFileData(string filePath) { return new FileData() diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index f642ab4..4984e40 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.IO.Compression; +using System.Net.Http.Headers; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using ThunderstoreCLI.Configuration; @@ -9,10 +10,11 @@ namespace ThunderstoreCLI.Commands; -public static class InstallCommand +public static partial class InstallCommand { // will match either ab-cd or ab-cd-123.456.7890 - internal static readonly Regex FullPackageNameRegex = new(@"^(\w+)-(\w+)(?:|-(\d+\.\d+\.\d+))$"); + [GeneratedRegex(@"^(?(?[\w-\.]+)-(?\w+))(?:|-(?\d+\.\d+\.\d+))$")] + internal static partial Regex FullPackageNameRegex(); public static async Task Run(Config config) { @@ -33,13 +35,14 @@ public static async Task Run(Config config) HttpClient http = new(); int returnCode; + Match packageMatch; if (File.Exists(package)) { returnCode = await InstallZip(config, http, def, profile, package, null); } - else if (FullPackageNameRegex.IsMatch(package)) + else if ((packageMatch = FullPackageNameRegex().Match(package)).Success) { - returnCode = await InstallFromRepository(config, http, def, profile, package); + returnCode = await InstallFromRepository(config, http, def, profile, packageMatch); } else { @@ -52,28 +55,29 @@ public static async Task Run(Config config) return returnCode; } - private static async Task InstallFromRepository(Config config, HttpClient http, GameDefinition game, ModProfile profile, string packageId) + private static async Task InstallFromRepository(Config config, HttpClient http, GameDefinition game, ModProfile profile, Match packageMatch) { - var packageParts = packageId.Split('-'); + PackageVersionData versionData; + Write.Light($"Downloading main package: {packageMatch.Groups["fullname"].Value}"); - PackageVersionData version; - Write.Light($"Downloading main package: {packageId}"); - if (packageParts.Length == 3) + var ns = packageMatch.Groups["namespace"]; + var name = packageMatch.Groups["name"]; + var version = packageMatch.Groups["version"]; + if (version.Success) { - var versionResponse = await http.SendAsync(config.Api.GetPackageVersionMetadata(packageParts[0], packageParts[1], packageParts[2])); + var versionResponse = await http.SendAsync(config.Api.GetPackageVersionMetadata(ns.Value, name.Value, version.Value)); versionResponse.EnsureSuccessStatusCode(); - version = (await PackageVersionData.DeserializeAsync(await versionResponse.Content.ReadAsStreamAsync()))!; + versionData = (await PackageVersionData.DeserializeAsync(await versionResponse.Content.ReadAsStreamAsync()))!; } else { - var packageResponse = await http.SendAsync(config.Api.GetPackageMetadata(packageParts[0], packageParts[1])); + var packageResponse = await http.SendAsync(config.Api.GetPackageMetadata(ns.Value, name.Value)); packageResponse.EnsureSuccessStatusCode(); - version = (await PackageData.DeserializeAsync(await packageResponse.Content.ReadAsStreamAsync()))!.LatestVersion!; + versionData = (await PackageData.DeserializeAsync(await packageResponse.Content.ReadAsStreamAsync()))!.LatestVersion!; } - var tempZipPath = await DownloadTemp(http, version); - var returnCode = await InstallZip(config, http, game, profile, tempZipPath, version.Namespace!); - File.Delete(tempZipPath); + var zipPath = await config.Cache.GetFileOrDownload($"{versionData.FullName}.zip", versionData.DownloadUrl!); + var returnCode = await InstallZip(config, http, game, profile, zipPath, versionData.Namespace!); return returnCode; } @@ -92,25 +96,39 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe if (dependenciesToInstall.Length > 0) { - var downloadTasks = dependenciesToInstall.Select(mod => DownloadTemp(http, mod.LatestVersion!)).ToArray(); + double totalSize = dependenciesToInstall.Select(d => (double) d.Versions![0].FileSize).Sum(); + string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; + int suffixIndex = 0; + while (totalSize >= 1024 && suffixIndex < suffixes.Length) + { + totalSize /= 1024; + suffixIndex++; + } + Write.Light($"Total estimated download size: {totalSize:F2} {suffixes[suffixIndex]}"); + + var downloadTasks = dependenciesToInstall.Select(mod => + { + var version = mod.Versions![0]; + return config.Cache.GetFileOrDownload($"{mod.Fullname}-{version.VersionNumber}.zip", version.DownloadUrl!); + }).ToArray(); var spinner = new ProgressSpinner("dependencies downloaded", downloadTasks); await spinner.Spin(); foreach (var (tempZipPath, package) in downloadTasks.Select(x => x.Result).Zip(dependenciesToInstall)) { - int returnCode = RunInstaller(game, profile, tempZipPath, package.Namespace); - File.Delete(tempZipPath); + var packageVersion = package.Versions![0]; + int returnCode = RunInstaller(game, profile, tempZipPath, package.Owner); if (returnCode == 0) { - Write.Success($"Installed mod: {package.Fullname}-{package.LatestVersion!.VersionNumber}"); + Write.Success($"Installed mod: {package.Fullname}-{packageVersion.VersionNumber}"); } else { - Write.Error($"Failed to install mod: {package.Fullname}-{package.LatestVersion!.VersionNumber}"); + Write.Error($"Failed to install mod: {package.Fullname}-{packageVersion.VersionNumber}"); return returnCode; } - profile.InstalledModVersions[package.Fullname!] = new PackageManifestV1(package.LatestVersion!); + profile.InstalledModVersions[package.Fullname!] = new PackageManifestV1(package, packageVersion); } } @@ -127,18 +145,6 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe return exitCode; } - // TODO: replace with a mod cache - private static async Task DownloadTemp(HttpClient http, PackageVersionData version) - { - string path = Path.GetTempFileName(); - await using var file = File.OpenWrite(path); - using var response = await http.SendAsync(new HttpRequestMessage(HttpMethod.Get, version.DownloadUrl!)); - response.EnsureSuccessStatusCode(); - var zipStream = await response.Content.ReadAsStreamAsync(); - await zipStream.CopyToAsync(file); - return path; - } - // TODO: conflict handling private static int RunInstaller(GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace) { diff --git a/ThunderstoreCLI/Configuration/Config.cs b/ThunderstoreCLI/Configuration/Config.cs index 27a992b..951272e 100644 --- a/ThunderstoreCLI/Configuration/Config.cs +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using ThunderstoreCLI.API; using ThunderstoreCLI.Models; +using ThunderstoreCLI.Utils; namespace ThunderstoreCLI.Configuration; @@ -21,9 +22,13 @@ public class Config private readonly Lazy api; public ApiHelper Api => api.Value; + private readonly Lazy cache; + public DownloadCache Cache => cache.Value; + private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitConfig initConfig, BuildConfig buildConfig, PublishConfig publishConfig, AuthConfig authConfig, ModManagementConfig modManagementConfig, GameImportConfig gameImportConfig, RunGameConfig runGameConfig) { api = new Lazy(() => new ApiHelper(this)); + cache = new Lazy(() => new DownloadCache(Path.Combine(GeneralConfig!.TcliConfig, "ModCache"))); GeneralConfig = generalConfig; PackageConfig = packageConfig; InitConfig = initConfig; diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs index 9c88b11..3837191 100644 --- a/ThunderstoreCLI/Game/GameDefinition.cs +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -46,7 +46,7 @@ public sealed class GameDefintionCollection : IEnumerable, IDisp private const string FILE_NAME = "GameDefintions.json"; private readonly string tcliDirectory; - private bool shouldWrite = true; + private bool shouldWrite = false; public List List { get; } internal static GameDefintionCollection FromDirectory(string tcliDirectory) => new(tcliDirectory); diff --git a/ThunderstoreCLI/Models/PackageListingV1.cs b/ThunderstoreCLI/Models/PackageListingV1.cs new file mode 100644 index 0000000..4a6743d --- /dev/null +++ b/ThunderstoreCLI/Models/PackageListingV1.cs @@ -0,0 +1,125 @@ +using Newtonsoft.Json; + +namespace ThunderstoreCLI.Models; + +public class PackageListingV1 : BaseJson +{ + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("full_name")] + public string? Fullname { get; set; } + + [JsonProperty("owner")] + public string? Owner { get; set; } + + [JsonProperty("package_url")] + public string? PackageUrl { get; set; } + + [JsonProperty("date_created")] + public DateTime DateCreated { get; set; } + + [JsonProperty("date_updated")] + public DateTime DateUpdated { get; set; } + + [JsonProperty("uuid4")] + public string? Uuid4 { get; set; } + + [JsonProperty("rating_score")] + public int RatingScore { get; set; } + + [JsonProperty("is_pinned")] + public bool IsPinned { get; set; } + + [JsonProperty("is_deprecated")] + public bool IsDeprecated { get; set; } + + [JsonProperty("has_nsfw_content")] + public bool HasNsfwContent { get; set; } + + [JsonProperty("categories")] + public string[]? Categories { get; set; } + + [JsonProperty("versions")] + public PackageVersionV1[]? Versions { get; set; } + + public PackageListingV1() { } + + public PackageListingV1(PackageData package) + { + Name = package.Name; + Fullname = package.Fullname; + Owner = package.Namespace; + PackageUrl = package.PackageUrl; + DateCreated = package.DateCreated; + DateUpdated = package.DateUpdated; + Uuid4 = null; + RatingScore = int.Parse(package.RatingScore!); + IsPinned = package.IsPinned; + IsDeprecated = package.IsDeprecated; + HasNsfwContent = package.CommunityListings!.Any(l => l.HasNsfwContent); + Categories = Array.Empty(); + Versions = new[] { new PackageVersionV1(package.LatestVersion!) }; + } +} + +public class PackageVersionV1 +{ + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("full_name")] + public string? FullName { get; set; } + + [JsonProperty("description")] + public string? Description { get; set; } + + [JsonProperty("icon")] + public string? Icon { get; set; } + + [JsonProperty("version_number")] + public string? VersionNumber { get; set; } + + [JsonProperty("dependencies")] + public string[]? Dependencies { get; set; } + + [JsonProperty("download_url")] + public string? DownloadUrl { get; set; } + + [JsonProperty("downloads")] + public int Downloads { get; set; } + + [JsonProperty("date_created")] + public DateTime DateCreated { get; set; } + + [JsonProperty("website_url")] + public string? WebsiteUrl { get; set; } + + [JsonProperty("is_active")] + public bool IsActive { get; set; } + + [JsonProperty("uuid4")] + public string? Uuid4 { get; set; } + + [JsonProperty("file_size")] + public int FileSize { get; set; } + + public PackageVersionV1() { } + + public PackageVersionV1(PackageVersionData version) + { + Name = version.Name; + FullName = version.FullName; + Description = version.Description; + Icon = version.Icon; + VersionNumber = version.VersionNumber; + Dependencies = version.Dependencies; + DownloadUrl = version.DownloadUrl; + Downloads = version.Downloads; + DateCreated = version.DateCreated; + WebsiteUrl = version.WebsiteUrl; + IsActive = version.IsActive; + Uuid4 = null; + FileSize = 0; + } +} diff --git a/ThunderstoreCLI/Models/PublishModels.cs b/ThunderstoreCLI/Models/PublishModels.cs index 482fe83..f4aa90d 100644 --- a/ThunderstoreCLI/Models/PublishModels.cs +++ b/ThunderstoreCLI/Models/PublishModels.cs @@ -149,7 +149,7 @@ public class PackageVersionData : BaseJson [JsonProperty("icon")] public string? Icon { get; set; } - [JsonProperty("dependencies")] public List? Dependencies { get; set; } + [JsonProperty("dependencies")] public string[]? Dependencies { get; set; } [JsonProperty("download_url")] public string? DownloadUrl { get; set; } diff --git a/ThunderstoreCLI/PackageManifestV1.cs b/ThunderstoreCLI/PackageManifestV1.cs index 6b1fc6c..3ea2b1b 100644 --- a/ThunderstoreCLI/PackageManifestV1.cs +++ b/ThunderstoreCLI/PackageManifestV1.cs @@ -37,4 +37,14 @@ public PackageManifestV1(PackageVersionData version) Dependencies = version.Dependencies?.ToArray() ?? Array.Empty(); WebsiteUrl = version.WebsiteUrl; } + + public PackageManifestV1(PackageListingV1 listing, PackageVersionV1 version) + { + Namespace = listing.Owner; + Name = listing.Name; + Description = version.Description; + VersionNumber = version.VersionNumber; + Dependencies = version.Dependencies?.ToArray() ?? Array.Empty(); + WebsiteUrl = version.WebsiteUrl; + } } diff --git a/ThunderstoreCLI/Utils/DownloadCache.cs b/ThunderstoreCLI/Utils/DownloadCache.cs new file mode 100644 index 0000000..26169ad --- /dev/null +++ b/ThunderstoreCLI/Utils/DownloadCache.cs @@ -0,0 +1,53 @@ +namespace ThunderstoreCLI.Utils; + +public sealed class DownloadCache +{ + public string CacheDirectory { get; } + + private HttpClient Client { get; } = new() + { + Timeout = TimeSpan.FromHours(1) + }; + + public DownloadCache(string cacheDirectory) + { + CacheDirectory = cacheDirectory; + } + + // Task instead of ValueTask here because these Tasks will be await'd multiple times (ValueTask does not allow that) + public Task GetFileOrDownload(string filename, string downloadUrl) + { + string fullPath = Path.Combine(CacheDirectory, filename); + if (File.Exists(fullPath)) + { + return Task.FromResult(fullPath); + } + + return DownloadFile(fullPath, downloadUrl); + } + + private async Task DownloadFile(string fullpath, string downloadUrl) + { + int tryCount = 0; +Retry: + try + { + var tempPath = fullpath + ".tmp"; + // copy into memory first to prevent canceled downloads creating files on the disk + await using FileStream tempStream = new(tempPath, FileMode.Create, FileAccess.Write); + await using var downloadStream = await Client.GetStreamAsync(downloadUrl); + await downloadStream.CopyToAsync(tempStream); + + File.Move(tempPath, fullpath); + } + catch + { + if (++tryCount == 5) + { + throw; + } + goto Retry; + } + return fullpath; + } +} diff --git a/ThunderstoreCLI/Utils/ModDependencyTree.cs b/ThunderstoreCLI/Utils/ModDependencyTree.cs index ed32a69..4211ba0 100644 --- a/ThunderstoreCLI/Utils/ModDependencyTree.cs +++ b/ThunderstoreCLI/Utils/ModDependencyTree.cs @@ -1,4 +1,7 @@ -using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Text.RegularExpressions; +using ThunderstoreCLI.Commands; using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Models; @@ -6,42 +9,86 @@ namespace ThunderstoreCLI.Utils; public static class ModDependencyTree { - public static IEnumerable Generate(Config config, HttpClient http, PackageManifestV1 root) + public static IEnumerable Generate(Config config, HttpClient http, PackageManifestV1 root) { - HashSet alreadyGottenPackages = new(); - foreach (var dependency in root.Dependencies!) + var cachePath = Path.Combine(config.GeneralConfig.TcliConfig, "package-ror2.json"); + string packagesJson; + if (!File.Exists(cachePath) || new FileInfo(cachePath).LastWriteTime.AddMinutes(5) < DateTime.Now) { - var depParts = dependency.Split('-'); - var depRequest = http.Send(config.Api.GetPackageMetadata(depParts[0], depParts[1])); - depRequest.EnsureSuccessStatusCode(); - var depData = PackageData.Deserialize(depRequest.Content.ReadAsStream()); - foreach (var package in GenerateInternal(config, http, depData!, package => alreadyGottenPackages.Contains(package.Fullname!))) + var packageResponse = http.Send(config.Api.GetPackagesV1()); + packageResponse.EnsureSuccessStatusCode(); + using var responseReader = new StreamReader(packageResponse.Content.ReadAsStream()); + packagesJson = responseReader.ReadToEnd(); + File.WriteAllText(cachePath, packagesJson); + } + else + { + packagesJson = File.ReadAllText(cachePath); + } + + var packages = PackageListingV1.DeserializeList(packagesJson)!; + + HashSet visited = new(); + foreach (var originalDep in root.Dependencies!) + { + var match = InstallCommand.FullPackageNameRegex().Match(originalDep); + var fullname = match.Groups["fullname"].Value; + var depPackage = packages.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.FullName); + if (depPackage == null) { - // this can happen on cyclical references, oh well - if (alreadyGottenPackages.Contains(package.Fullname!)) + continue; + } + foreach (var dependency in GenerateInner(packages, config, http, depPackage, p => visited.Contains(p.Fullname!))) + { + // can happen on cycles, oh well + if (visited.Contains(dependency.Fullname!)) + { continue; - - alreadyGottenPackages.Add(package.Fullname!); - yield return package; + } + visited.Add(dependency.Fullname!); + yield return dependency; } } } - private static IEnumerable GenerateInternal(Config config, HttpClient http, PackageData root, Predicate alreadyGotten) + + private static IEnumerable GenerateInner(List packages, Config config, HttpClient http, PackageListingV1 root, Predicate visited) { - if (alreadyGotten(root)) + if (visited(root)) + { yield break; + } - foreach (var dependency in root.LatestVersion!.Dependencies!) + foreach (var dependency in root.Versions!.First().Dependencies!) { - var depParts = dependency.Split('-'); - var depRequest = http.Send(config.Api.GetPackageMetadata(depParts[0], depParts[1])); - depRequest.EnsureSuccessStatusCode(); - var depData = PackageData.Deserialize(depRequest.Content.ReadAsStream()); - foreach (var package in GenerateInternal(config, http, depData!, alreadyGotten)) + var match = InstallCommand.FullPackageNameRegex().Match(dependency); + var fullname = match.Groups["fullname"].Value; + var package = packages.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.Fullname!); + if (package == null) + { + continue; + } + foreach (var innerPackage in GenerateInner(packages, config, http, package, visited)) { - yield return package; + yield return innerPackage; } } + yield return root; } + + private static PackageListingV1? AttemptResolveExperimental(Config config, HttpClient http, Match nameMatch, string neededBy) + { + var response = http.Send(config.Api.GetPackageMetadata(nameMatch.Groups["namespace"].Value, nameMatch.Groups["name"].Value)); + if (response.StatusCode == HttpStatusCode.NotFound) + { + Write.Warn($"Failed to resolve dependency {nameMatch.Groups["fullname"].Value} for {neededBy}, continuing without it."); + return null; + } + response.EnsureSuccessStatusCode(); + using var reader = new StreamReader(response.Content.ReadAsStream()); + var data = PackageData.Deserialize(reader.ReadToEnd()); + + Write.Warn($"Package {data!.Fullname} (needed by {neededBy}) exists in different community, ignoring"); + return null; + } } diff --git a/tcli-bepinex-installer/src/main.rs b/tcli-bepinex-installer/src/main.rs index 0cb49fe..2df0504 100644 --- a/tcli-bepinex-installer/src/main.rs +++ b/tcli-bepinex-installer/src/main.rs @@ -46,8 +46,8 @@ pub enum Error { PathDoesNotExist(PathBuf), #[error("ZIP file does not contain a Thunderstore manifest")] NoZipManifest, - #[error("Invalid manifest in ZIP")] - InvalidManifest, + #[error("Invalid manifest in ZIP, serde_json error: {0}")] + InvalidManifest(serde_json::Error), #[error("Malformed zip")] MalformedZip, #[error("Manifest does not contain a namespace and no backup was given, namespaces are required for mod installs")] @@ -63,6 +63,7 @@ struct ManifestV1 { pub name: String, pub description: String, pub version_number: String, + #[serde(default)] pub dependencies: Vec, pub website_url: String, } @@ -119,12 +120,20 @@ fn install(game_dir: PathBuf, bep_dir: PathBuf, zip_path: PathBuf, namespace_bac bail!(Error::NoZipManifest); } - let manifest_file = zip.by_name("manifest.json")?; + let mut manifest_file = zip.by_name("manifest.json")?; + + let mut manifest_text = String::new(); + manifest_file.read_to_string(&mut manifest_text).unwrap(); + if manifest_text.starts_with('\u{FEFF}') { + manifest_text.remove(0); + } + + drop(manifest_file); let manifest: ManifestV1 = - serde_json::from_reader(manifest_file).map_err(|_| Error::InvalidManifest)?; + serde_json::from_str(&manifest_text).map_err(Error::InvalidManifest)?; - if manifest.name.starts_with("BepInEx") { + if manifest.name.starts_with("BepInEx") && zip.file_names().any(|f| f.ends_with("winhttp.dll")) { install_bepinex(game_dir, bep_dir, zip) } else { install_mod(bep_dir, zip, manifest, namespace_backup) @@ -216,6 +225,10 @@ fn install_mod( } let filepath = file.enclosed_name().ok_or(Error::MalformedZip)?.to_owned(); + let filename = match filepath.file_name() { + Some(name) => name, + None => continue + }; let mut out_path = None; 'outer: for remap in remaps.keys() { @@ -227,7 +240,7 @@ fn install_mod( } } if out_path.is_none() { - out_path = Some(default_remap.join(filepath.file_name().unwrap())); + out_path = Some(default_remap.join(filename)); } let full_out_path = bep_dir.join(out_path.unwrap()); From 20c71420556defe77b32cee9eef4aa9c413679b8 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Mon, 14 Nov 2022 09:22:40 -0600 Subject: [PATCH 20/25] Cache all community package.json lists, not just ror2 --- ThunderstoreCLI/Commands/InstallCommand.cs | 21 ++++++----- ThunderstoreCLI/Utils/ModDependencyTree.cs | 42 ++++++++++++---------- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index 4984e40..dad751d 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -38,7 +38,7 @@ public static async Task Run(Config config) Match packageMatch; if (File.Exists(package)) { - returnCode = await InstallZip(config, http, def, profile, package, null); + returnCode = await InstallZip(config, http, def, profile, package, null, null); } else if ((packageMatch = FullPackageNameRegex().Match(package)).Success) { @@ -57,7 +57,7 @@ public static async Task Run(Config config) private static async Task InstallFromRepository(Config config, HttpClient http, GameDefinition game, ModProfile profile, Match packageMatch) { - PackageVersionData versionData; + PackageVersionData? versionData = null; Write.Light($"Downloading main package: {packageMatch.Groups["fullname"].Value}"); var ns = packageMatch.Groups["namespace"]; @@ -69,19 +69,18 @@ private static async Task InstallFromRepository(Config config, HttpClient h versionResponse.EnsureSuccessStatusCode(); versionData = (await PackageVersionData.DeserializeAsync(await versionResponse.Content.ReadAsStreamAsync()))!; } - else - { - var packageResponse = await http.SendAsync(config.Api.GetPackageMetadata(ns.Value, name.Value)); - packageResponse.EnsureSuccessStatusCode(); - versionData = (await PackageData.DeserializeAsync(await packageResponse.Content.ReadAsStreamAsync()))!.LatestVersion!; - } + var packageResponse = await http.SendAsync(config.Api.GetPackageMetadata(ns.Value, name.Value)); + packageResponse.EnsureSuccessStatusCode(); + var packageData = await PackageData.DeserializeAsync(await packageResponse.Content.ReadAsStreamAsync()); + + versionData ??= packageData!.LatestVersion!; var zipPath = await config.Cache.GetFileOrDownload($"{versionData.FullName}.zip", versionData.DownloadUrl!); - var returnCode = await InstallZip(config, http, game, profile, zipPath, versionData.Namespace!); + var returnCode = await InstallZip(config, http, game, profile, zipPath, versionData.Namespace!, packageData!.CommunityListings!.First().Community); return returnCode; } - private static async Task InstallZip(Config config, HttpClient http, GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace) + private static async Task InstallZip(Config config, HttpClient http, GameDefinition game, ModProfile profile, string zipPath, string? backupNamespace, string? sourceCommunity) { using var zip = ZipFile.OpenRead(zipPath); var manifestFile = zip.GetEntry("manifest.json") ?? throw new CommandFatalException("Package zip needs a manifest.json!"); @@ -90,7 +89,7 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe manifest.Namespace ??= backupNamespace; - var dependenciesToInstall = ModDependencyTree.Generate(config, http, manifest) + var dependenciesToInstall = ModDependencyTree.Generate(config, http, manifest, sourceCommunity) .Where(dependency => !profile.InstalledModVersions.ContainsKey(dependency.Fullname!)) .ToArray(); diff --git a/ThunderstoreCLI/Utils/ModDependencyTree.cs b/ThunderstoreCLI/Utils/ModDependencyTree.cs index 4211ba0..a43ab28 100644 --- a/ThunderstoreCLI/Utils/ModDependencyTree.cs +++ b/ThunderstoreCLI/Utils/ModDependencyTree.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Net; using System.Text.RegularExpressions; using ThunderstoreCLI.Commands; @@ -9,31 +8,36 @@ namespace ThunderstoreCLI.Utils; public static class ModDependencyTree { - public static IEnumerable Generate(Config config, HttpClient http, PackageManifestV1 root) + public static IEnumerable Generate(Config config, HttpClient http, PackageManifestV1 root, string? sourceCommunity) { - var cachePath = Path.Combine(config.GeneralConfig.TcliConfig, "package-ror2.json"); - string packagesJson; - if (!File.Exists(cachePath) || new FileInfo(cachePath).LastWriteTime.AddMinutes(5) < DateTime.Now) - { - var packageResponse = http.Send(config.Api.GetPackagesV1()); - packageResponse.EnsureSuccessStatusCode(); - using var responseReader = new StreamReader(packageResponse.Content.ReadAsStream()); - packagesJson = responseReader.ReadToEnd(); - File.WriteAllText(cachePath, packagesJson); - } - else + List? packages = null; + + if (sourceCommunity != null) { - packagesJson = File.ReadAllText(cachePath); - } + var cachePath = Path.Combine(config.GeneralConfig.TcliConfig, $"package-{sourceCommunity}.json"); + string packagesJson; + if (!File.Exists(cachePath) || new FileInfo(cachePath).LastWriteTime.AddMinutes(5) < DateTime.Now) + { + var packageResponse = http.Send(config.Api.GetPackagesV1()); + packageResponse.EnsureSuccessStatusCode(); + using var responseReader = new StreamReader(packageResponse.Content.ReadAsStream()); + packagesJson = responseReader.ReadToEnd(); + File.WriteAllText(cachePath, packagesJson); + } + else + { + packagesJson = File.ReadAllText(cachePath); + } - var packages = PackageListingV1.DeserializeList(packagesJson)!; + packages = PackageListingV1.DeserializeList(packagesJson)!; + } HashSet visited = new(); foreach (var originalDep in root.Dependencies!) { var match = InstallCommand.FullPackageNameRegex().Match(originalDep); var fullname = match.Groups["fullname"].Value; - var depPackage = packages.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.FullName); + var depPackage = packages?.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.FullName); if (depPackage == null) { continue; @@ -51,7 +55,7 @@ public static IEnumerable Generate(Config config, HttpClient h } } - private static IEnumerable GenerateInner(List packages, Config config, HttpClient http, PackageListingV1 root, Predicate visited) + private static IEnumerable GenerateInner(List? packages, Config config, HttpClient http, PackageListingV1 root, Predicate visited) { if (visited(root)) { @@ -62,7 +66,7 @@ private static IEnumerable GenerateInner(List p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.Fullname!); + var package = packages?.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.Fullname!); if (package == null) { continue; From 3a52213ad9be6e901a4e6b0d9788aaa5dc9c6a44 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Mon, 14 Nov 2022 09:27:32 -0600 Subject: [PATCH 21/25] Fix weird incorrect MD5 logic --- ThunderstoreCLI/Commands/PublishCommand.cs | 36 +++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/ThunderstoreCLI/Commands/PublishCommand.cs b/ThunderstoreCLI/Commands/PublishCommand.cs index 5d5bed5..2ab0db3 100644 --- a/ThunderstoreCLI/Commands/PublishCommand.cs +++ b/ThunderstoreCLI/Commands/PublishCommand.cs @@ -1,5 +1,6 @@ using System.Net; using System.Security.Cryptography; +using System.Text; using ThunderstoreCLI.Configuration; using ThunderstoreCLI.Models; using ThunderstoreCLI.Utils; @@ -207,14 +208,41 @@ private static void PublishPackageRequest(Config config, string uploadUuid) stream.Seek(part.Offset, SeekOrigin.Begin); byte[] hash; - using (var md5 = MD5.Create()) + var chunk = new MemoryStream(); + const int blocksize = 65536; + + using (var reader = new BinaryReader(stream, Encoding.Default, true)) { - hash = await md5.ComputeHashAsync(stream); + using (var md5 = MD5.Create()) + { + md5.Initialize(); + var length = part.Length; + + while (length > blocksize) + { + length -= blocksize; + var bytes = reader.ReadBytes(blocksize); + md5.TransformBlock(bytes, 0, blocksize, null, 0); + await chunk.WriteAsync(bytes); + } + + var finalBytes = reader.ReadBytes(length); + md5.TransformFinalBlock(finalBytes, 0, length); + + if (md5.Hash is null) + { + Write.ErrorExit($"MD5 hashing failed for part #{part.PartNumber})"); + throw new PublishCommandException(); + } + + hash = md5.Hash; + await chunk.WriteAsync(finalBytes); + chunk.Position = 0; + } } var request = new HttpRequestMessage(HttpMethod.Put, part.Url); - stream.Seek(part.Offset, SeekOrigin.Begin); - request.Content = new StreamContent(stream); + request.Content = new StreamContent(chunk); request.Content.Headers.ContentMD5 = hash; request.Content.Headers.ContentLength = part.Length; From 30fd3bd41b7f924e9a3ee837b411d09ccd9cd0b0 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Fri, 18 Nov 2022 04:01:25 -0600 Subject: [PATCH 22/25] Address review comments --- ThunderstoreCLI/Commands/ImportGameCommand.cs | 4 +- ThunderstoreCLI/Commands/InstallCommand.cs | 15 ++-- ThunderstoreCLI/Commands/PublishCommand.cs | 11 +-- ThunderstoreCLI/Commands/RunCommand.cs | 2 +- ThunderstoreCLI/Commands/UninstallCommand.cs | 4 +- ThunderstoreCLI/Configuration/Config.cs | 69 +++++++++---------- ThunderstoreCLI/Game/GameDefinition.cs | 22 +++--- ThunderstoreCLI/ThunderstoreCLI.csproj | 9 ++- ThunderstoreCLI/Utils/DownloadCache.cs | 6 +- ThunderstoreCLI/Utils/MiscUtils.cs | 13 ++++ ThunderstoreCLI/Utils/SteamUtils.cs | 22 ++---- 11 files changed, 77 insertions(+), 100 deletions(-) diff --git a/ThunderstoreCLI/Commands/ImportGameCommand.cs b/ThunderstoreCLI/Commands/ImportGameCommand.cs index e8560eb..8fe76a4 100644 --- a/ThunderstoreCLI/Commands/ImportGameCommand.cs +++ b/ThunderstoreCLI/Commands/ImportGameCommand.cs @@ -29,9 +29,9 @@ public static int Run(Config config) throw new CommandFatalException("Game not installed"); } - using GameDefintionCollection collection = GameDefintionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + var collection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig); collection.List.Add(def); - collection.Validate(); + collection.Write(); Write.Success($"Successfully imported {def.Name} ({def.Identifier}) with install folder \"{def.InstallDirectory}\""); diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index dad751d..e26a20e 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -18,7 +18,7 @@ public static partial class InstallCommand public static async Task Run(Config config) { - using var defCollection = GameDefintionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + var defCollection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig); var defs = defCollection.List; GameDefinition? def = defs.FirstOrDefault(x => x.Identifier == config.ModManagementConfig.GameIdentifer); if (def == null) @@ -50,7 +50,7 @@ public static async Task Run(Config config) } if (returnCode == 0) - defCollection.Validate(); + defCollection.Write(); return returnCode; } @@ -95,15 +95,8 @@ private static async Task InstallZip(Config config, HttpClient http, GameDe if (dependenciesToInstall.Length > 0) { - double totalSize = dependenciesToInstall.Select(d => (double) d.Versions![0].FileSize).Sum(); - string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; - int suffixIndex = 0; - while (totalSize >= 1024 && suffixIndex < suffixes.Length) - { - totalSize /= 1024; - suffixIndex++; - } - Write.Light($"Total estimated download size: {totalSize:F2} {suffixes[suffixIndex]}"); + var totalSize = MiscUtils.GetSizeString(dependenciesToInstall.Select(d => d.Versions![0].FileSize).Sum()); + Write.Light($"Total estimated download size: "); var downloadTasks = dependenciesToInstall.Select(mod => { diff --git a/ThunderstoreCLI/Commands/PublishCommand.cs b/ThunderstoreCLI/Commands/PublishCommand.cs index 2ab0db3..0c78956 100644 --- a/ThunderstoreCLI/Commands/PublishCommand.cs +++ b/ThunderstoreCLI/Commands/PublishCommand.cs @@ -161,16 +161,7 @@ private static UploadInitiateData InitiateUploadRequest(Config config, string fi throw new PublishCommandException(); } - string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; - int suffixIndex = 0; - long size = uploadData.Metadata.Size; - while (size >= 1024 && suffixIndex < suffixes.Length) - { - size /= 1024; - suffixIndex++; - } - - var details = $"({size}{suffixes[suffixIndex]}) in {uploadData.UploadUrls.Length} chunks..."; + var details = $"({MiscUtils.GetSizeString(uploadData.Metadata.Size)}) in {uploadData.UploadUrls.Length} chunks..."; Write.WithNL($"Uploading {Cyan(uploadData.Metadata.Filename)} {details}", after: true); return uploadData; diff --git a/ThunderstoreCLI/Commands/RunCommand.cs b/ThunderstoreCLI/Commands/RunCommand.cs index 9f4d058..066e2e8 100644 --- a/ThunderstoreCLI/Commands/RunCommand.cs +++ b/ThunderstoreCLI/Commands/RunCommand.cs @@ -12,7 +12,7 @@ public static class RunCommand { public static int Run(Config config) { - GameDefintionCollection collection = GameDefintionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + var collection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig); var def = collection.FirstOrDefault(g => g.Identifier == config.RunGameConfig.GameName); if (def == null) diff --git a/ThunderstoreCLI/Commands/UninstallCommand.cs b/ThunderstoreCLI/Commands/UninstallCommand.cs index 016c371..ed57c79 100644 --- a/ThunderstoreCLI/Commands/UninstallCommand.cs +++ b/ThunderstoreCLI/Commands/UninstallCommand.cs @@ -10,7 +10,7 @@ public static class UninstallCommand { public static int Run(Config config) { - using var defCollection = GameDefintionCollection.FromDirectory(config.GeneralConfig.TcliConfig); + var defCollection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig); GameDefinition? def = defCollection.FirstOrDefault(def => def.Identifier == config.ModManagementConfig.GameIdentifer); if (def == null) { @@ -99,7 +99,7 @@ public static int Run(Config config) throw new CommandFatalException($"The following mods failed to uninstall:\n{string.Join('\n', failedMods)}"); } - defCollection.Validate(); + defCollection.Write(); return 0; } diff --git a/ThunderstoreCLI/Configuration/Config.cs b/ThunderstoreCLI/Configuration/Config.cs index 951272e..0e6b2d3 100644 --- a/ThunderstoreCLI/Configuration/Config.cs +++ b/ThunderstoreCLI/Configuration/Config.cs @@ -8,15 +8,15 @@ namespace ThunderstoreCLI.Configuration; public class Config { // ReSharper disable AutoPropertyCanBeMadeGetOnly.Local - public GeneralConfig GeneralConfig { get; private set; } - public PackageConfig PackageConfig { get; private set; } - public InitConfig InitConfig { get; private set; } - public BuildConfig BuildConfig { get; private set; } - public PublishConfig PublishConfig { get; private set; } - public AuthConfig AuthConfig { get; private set; } - public ModManagementConfig ModManagementConfig { get; private set; } - public GameImportConfig GameImportConfig { get; private set; } - public RunGameConfig RunGameConfig { get; private set; } + public required GeneralConfig GeneralConfig { get; init; } + public required PackageConfig PackageConfig { get; init; } + public required InitConfig InitConfig { get; init; } + public required BuildConfig BuildConfig { get; init; } + public required PublishConfig PublishConfig { get; init; } + public required AuthConfig AuthConfig { get; init; } + public required ModManagementConfig ModManagementConfig { get; init; } + public required GameImportConfig GameImportConfig { get; init; } + public required RunGameConfig RunGameConfig { get; init; } // ReSharper restore AutoPropertyCanBeMadeGetOnly.Local private readonly Lazy api; @@ -25,19 +25,10 @@ public class Config private readonly Lazy cache; public DownloadCache Cache => cache.Value; - private Config(GeneralConfig generalConfig, PackageConfig packageConfig, InitConfig initConfig, BuildConfig buildConfig, PublishConfig publishConfig, AuthConfig authConfig, ModManagementConfig modManagementConfig, GameImportConfig gameImportConfig, RunGameConfig runGameConfig) + private Config() { api = new Lazy(() => new ApiHelper(this)); cache = new Lazy(() => new DownloadCache(Path.Combine(GeneralConfig!.TcliConfig, "ModCache"))); - GeneralConfig = generalConfig; - PackageConfig = packageConfig; - InitConfig = initConfig; - BuildConfig = buildConfig; - PublishConfig = publishConfig; - AuthConfig = authConfig; - ModManagementConfig = modManagementConfig; - GameImportConfig = gameImportConfig; - RunGameConfig = runGameConfig; } public static Config FromCLI(IConfigProvider cliConfig) { @@ -120,28 +111,30 @@ public PackageUploadMetadata GetUploadMetadata(string fileUuid) public static Config Parse(IConfigProvider[] configProviders) { - var generalConfig = new GeneralConfig(); - var packageMeta = new PackageConfig(); - var initConfig = new InitConfig(); - var buildConfig = new BuildConfig(); - var publishConfig = new PublishConfig(); - var authConfig = new AuthConfig(); - var modManagementConfig = new ModManagementConfig(); - var gameImportConfig = new GameImportConfig(); - var runGameConfig = new RunGameConfig(); - var result = new Config(generalConfig, packageMeta, initConfig, buildConfig, publishConfig, authConfig, modManagementConfig, gameImportConfig, runGameConfig); + Config result = new() + { + GeneralConfig = new GeneralConfig(), + PackageConfig = new PackageConfig(), + InitConfig = new InitConfig(), + BuildConfig = new BuildConfig(), + PublishConfig = new PublishConfig(), + AuthConfig = new AuthConfig(), + ModManagementConfig = new ModManagementConfig(), + GameImportConfig = new GameImportConfig(), + RunGameConfig = new RunGameConfig(), + }; foreach (var provider in configProviders) { provider.Parse(result); - Merge(generalConfig, provider.GetGeneralConfig(), false); - Merge(packageMeta, provider.GetPackageMeta(), false); - Merge(initConfig, provider.GetInitConfig(), false); - Merge(buildConfig, provider.GetBuildConfig(), false); - Merge(publishConfig, provider.GetPublishConfig(), false); - Merge(authConfig, provider.GetAuthConfig(), false); - Merge(modManagementConfig, provider.GetModManagementConfig(), false); - Merge(gameImportConfig, provider.GetGameImportConfig(), false); - Merge(runGameConfig, provider.GetRunGameConfig(), false); + Merge(result.GeneralConfig, provider.GetGeneralConfig(), false); + Merge(result.PackageConfig, provider.GetPackageMeta(), false); + Merge(result.InitConfig, provider.GetInitConfig(), false); + Merge(result.BuildConfig, provider.GetBuildConfig(), false); + Merge(result.PublishConfig, provider.GetPublishConfig(), false); + Merge(result.AuthConfig, provider.GetAuthConfig(), false); + Merge(result.ModManagementConfig, provider.GetModManagementConfig(), false); + Merge(result.GameImportConfig, provider.GetGameImportConfig(), false); + Merge(result.RunGameConfig, provider.GetRunGameConfig(), false); } return result; } diff --git a/ThunderstoreCLI/Game/GameDefinition.cs b/ThunderstoreCLI/Game/GameDefinition.cs index 3837191..7b54739 100644 --- a/ThunderstoreCLI/Game/GameDefinition.cs +++ b/ThunderstoreCLI/Game/GameDefinition.cs @@ -41,17 +41,16 @@ internal GameDefinition(string id, string name, string installDirectory, GamePla } } -public sealed class GameDefintionCollection : IEnumerable, IDisposable +public sealed class GameDefinitionCollection : IEnumerable { - private const string FILE_NAME = "GameDefintions.json"; + private const string FILE_NAME = "GameDefinitions.json"; private readonly string tcliDirectory; - private bool shouldWrite = false; public List List { get; } - internal static GameDefintionCollection FromDirectory(string tcliDirectory) => new(tcliDirectory); + internal static GameDefinitionCollection FromDirectory(string tcliDirectory) => new(tcliDirectory); - private GameDefintionCollection(string tcliDir) + private GameDefinitionCollection(string tcliDir) { tcliDirectory = tcliDir; var filename = Path.Combine(tcliDirectory, FILE_NAME); @@ -61,18 +60,13 @@ private GameDefintionCollection(string tcliDir) List = new(); } - public void Validate() => shouldWrite = true; - - public IEnumerator GetEnumerator() => List.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => List.GetEnumerator(); - - public void Dispose() + public void Write() { - if (!shouldWrite) - return; File.WriteAllText(Path.Combine(tcliDirectory, FILE_NAME), List.SerializeList(BaseJson.IndentedSettings)); - shouldWrite = false; } + + public IEnumerator GetEnumerator() => List.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => List.GetEnumerator(); } public enum GamePlatform diff --git a/ThunderstoreCLI/ThunderstoreCLI.csproj b/ThunderstoreCLI/ThunderstoreCLI.csproj index e9ab141..3b04b80 100644 --- a/ThunderstoreCLI/ThunderstoreCLI.csproj +++ b/ThunderstoreCLI/ThunderstoreCLI.csproj @@ -23,7 +23,6 @@ true $(AssemblyName) enable - true @@ -77,8 +76,12 @@ - - + + true + + + true + diff --git a/ThunderstoreCLI/Utils/DownloadCache.cs b/ThunderstoreCLI/Utils/DownloadCache.cs index 26169ad..04fceec 100644 --- a/ThunderstoreCLI/Utils/DownloadCache.cs +++ b/ThunderstoreCLI/Utils/DownloadCache.cs @@ -28,17 +28,15 @@ public Task GetFileOrDownload(string filename, string downloadUrl) private async Task DownloadFile(string fullpath, string downloadUrl) { + var tempPath = fullpath + ".tmp"; int tryCount = 0; Retry: try { - var tempPath = fullpath + ".tmp"; // copy into memory first to prevent canceled downloads creating files on the disk await using FileStream tempStream = new(tempPath, FileMode.Create, FileAccess.Write); await using var downloadStream = await Client.GetStreamAsync(downloadUrl); await downloadStream.CopyToAsync(tempStream); - - File.Move(tempPath, fullpath); } catch { @@ -48,6 +46,8 @@ private async Task DownloadFile(string fullpath, string downloadUrl) } goto Retry; } + + File.Move(tempPath, fullpath); return fullpath; } } diff --git a/ThunderstoreCLI/Utils/MiscUtils.cs b/ThunderstoreCLI/Utils/MiscUtils.cs index 024f80c..84d3626 100644 --- a/ThunderstoreCLI/Utils/MiscUtils.cs +++ b/ThunderstoreCLI/Utils/MiscUtils.cs @@ -69,4 +69,17 @@ public static async Task FetchReleaseInformation() return await response.Content.ReadAsStringAsync(); } + + public static string GetSizeString(long byteSize) + { + double finalSize = byteSize; + string[] suffixes = { "B", "KB", "MB", "GB", "TB" }; + int suffixIndex = 0; + while (finalSize >= 1024 && suffixIndex < suffixes.Length) + { + finalSize /= 1024; + suffixIndex++; + } + return $"{byteSize:F2} {suffixes[suffixIndex]}"; + } } diff --git a/ThunderstoreCLI/Utils/SteamUtils.cs b/ThunderstoreCLI/Utils/SteamUtils.cs index 2d80644..f9d7bd9 100644 --- a/ThunderstoreCLI/Utils/SteamUtils.cs +++ b/ThunderstoreCLI/Utils/SteamUtils.cs @@ -232,28 +232,18 @@ public static bool ForceLoadProton(string steamAppId, string[] dllsToEnable) string[] lines = File.ReadAllLines(path); - int start = -1; - for (int i = 0; i < lines.Length; i++) - { - if (lines[i].StartsWith(@"[Software\\Wine\\DllOverrides]")) - { - start = i + 2; - break; - } - } + int start = Array.FindIndex(lines, l => l.StartsWith(@"[Software\\Wine\\DllOverrides]")); if (start == -1) { return false; } + start += 2; - int end = lines.Length - 1; - for (int i = start; i < lines.Length; i++) + int end = Array.FindIndex(lines, start, l => l.Length == 0); + + if (end == -1) { - if (lines[i].Length == 0) - { - end = i; - break; - } + end = lines.Length - 1; } bool written = false; From 3f96286e150fc4d550a04055e25d50370b03aac6 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Sat, 26 Nov 2022 04:27:32 -0600 Subject: [PATCH 23/25] Fix multitargeting errors; further review comments --- ThunderstoreCLI/Commands/InstallCommand.cs | 7 +++-- ThunderstoreCLI/Utils/ActionUtils.cs | 30 ++++++++++++++++++++++ ThunderstoreCLI/Utils/DownloadCache.cs | 15 +++-------- ThunderstoreCLI/Utils/ModDependencyTree.cs | 4 +-- ThunderstoreCLI/Utils/SteamUtils.cs | 2 +- 5 files changed, 39 insertions(+), 19 deletions(-) create mode 100644 ThunderstoreCLI/Utils/ActionUtils.cs diff --git a/ThunderstoreCLI/Commands/InstallCommand.cs b/ThunderstoreCLI/Commands/InstallCommand.cs index e26a20e..65f610c 100644 --- a/ThunderstoreCLI/Commands/InstallCommand.cs +++ b/ThunderstoreCLI/Commands/InstallCommand.cs @@ -13,8 +13,7 @@ namespace ThunderstoreCLI.Commands; public static partial class InstallCommand { // will match either ab-cd or ab-cd-123.456.7890 - [GeneratedRegex(@"^(?(?[\w-\.]+)-(?\w+))(?:|-(?\d+\.\d+\.\d+))$")] - internal static partial Regex FullPackageNameRegex(); + internal static Regex FullPackageNameRegex = new Regex(@"^(?(?[\w-\.]+)-(?\w+))(?:|-(?\d+\.\d+\.\d+))$"); public static async Task Run(Config config) { @@ -35,12 +34,12 @@ public static async Task Run(Config config) HttpClient http = new(); int returnCode; - Match packageMatch; + Match packageMatch = FullPackageNameRegex.Match(package); if (File.Exists(package)) { returnCode = await InstallZip(config, http, def, profile, package, null, null); } - else if ((packageMatch = FullPackageNameRegex().Match(package)).Success) + else if (packageMatch.Success) { returnCode = await InstallFromRepository(config, http, def, profile, packageMatch); } diff --git a/ThunderstoreCLI/Utils/ActionUtils.cs b/ThunderstoreCLI/Utils/ActionUtils.cs new file mode 100644 index 0000000..5d11c8f --- /dev/null +++ b/ThunderstoreCLI/Utils/ActionUtils.cs @@ -0,0 +1,30 @@ +namespace ThunderstoreCLI.Utils; + +public static class ActionUtils +{ + public static void Retry(int maxTryCount, Action action) + { + for (int i = 0; i < maxTryCount; i++) + { + try + { + action(); + return; + } + catch { } + } + } + + public static async Task RetryAsync(int maxTryCount, Func action) + { + for (int i = 0; i < maxTryCount; i++) + { + try + { + await action(); + return; + } + catch { } + } + } +} diff --git a/ThunderstoreCLI/Utils/DownloadCache.cs b/ThunderstoreCLI/Utils/DownloadCache.cs index 04fceec..dc3b936 100644 --- a/ThunderstoreCLI/Utils/DownloadCache.cs +++ b/ThunderstoreCLI/Utils/DownloadCache.cs @@ -29,23 +29,14 @@ public Task GetFileOrDownload(string filename, string downloadUrl) private async Task DownloadFile(string fullpath, string downloadUrl) { var tempPath = fullpath + ".tmp"; - int tryCount = 0; -Retry: - try + + await ActionUtils.RetryAsync(5, async () => { // copy into memory first to prevent canceled downloads creating files on the disk await using FileStream tempStream = new(tempPath, FileMode.Create, FileAccess.Write); await using var downloadStream = await Client.GetStreamAsync(downloadUrl); await downloadStream.CopyToAsync(tempStream); - } - catch - { - if (++tryCount == 5) - { - throw; - } - goto Retry; - } + }); File.Move(tempPath, fullpath); return fullpath; diff --git a/ThunderstoreCLI/Utils/ModDependencyTree.cs b/ThunderstoreCLI/Utils/ModDependencyTree.cs index a43ab28..0343736 100644 --- a/ThunderstoreCLI/Utils/ModDependencyTree.cs +++ b/ThunderstoreCLI/Utils/ModDependencyTree.cs @@ -35,7 +35,7 @@ public static IEnumerable Generate(Config config, HttpClient h HashSet visited = new(); foreach (var originalDep in root.Dependencies!) { - var match = InstallCommand.FullPackageNameRegex().Match(originalDep); + var match = InstallCommand.FullPackageNameRegex.Match(originalDep); var fullname = match.Groups["fullname"].Value; var depPackage = packages?.Find(p => p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.FullName); if (depPackage == null) @@ -64,7 +64,7 @@ private static IEnumerable GenerateInner(List p.Fullname == fullname) ?? AttemptResolveExperimental(config, http, match, root.Fullname!); if (package == null) diff --git a/ThunderstoreCLI/Utils/SteamUtils.cs b/ThunderstoreCLI/Utils/SteamUtils.cs index f9d7bd9..fab73e3 100644 --- a/ThunderstoreCLI/Utils/SteamUtils.cs +++ b/ThunderstoreCLI/Utils/SteamUtils.cs @@ -225,7 +225,7 @@ public static bool IsProtonGame(string steamAppId) public static bool ForceLoadProton(string steamAppId, string[] dllsToEnable) { var path = Path.Combine(Path.GetDirectoryName(GetAcfPath(steamAppId))!, "compatdata", steamAppId, "pfx", "user.reg"); - if (!Path.Exists(path)) + if (!File.Exists(path)) { return false; } From 528ec084c70d223a04c75e4f1f9a766bbe58ec91 Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Sat, 26 Nov 2022 05:12:31 -0600 Subject: [PATCH 24/25] Hopefully last round of errors --- ThunderstoreCLI/API/ApiHelper.cs | 9 +++++++++ ThunderstoreCLI/Utils/ActionUtils.cs | 20 ++++++++++++++++---- ThunderstoreCLI/Utils/DownloadCache.cs | 4 ++++ ThunderstoreCLI/Utils/ModDependencyTree.cs | 2 +- ThunderstoreCLI/Utils/SteamUtils.cs | 8 ++++++-- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/ThunderstoreCLI/API/ApiHelper.cs b/ThunderstoreCLI/API/ApiHelper.cs index ac77097..afd05ef 100644 --- a/ThunderstoreCLI/API/ApiHelper.cs +++ b/ThunderstoreCLI/API/ApiHelper.cs @@ -29,6 +29,7 @@ public ApiHelper(Config config) private const string V1 = "api/v1/"; private const string EXPERIMENTAL = "api/experimental/"; + private const string COMMUNITY = "c/"; public HttpRequestMessage SubmitPackage(string fileUuid) { @@ -97,6 +98,14 @@ public HttpRequestMessage GetPackagesV1() .GetRequest(); } + public HttpRequestMessage GetPackagesV1(string community) + { + return BaseRequestBuilder + .StartNew() + .WithEndpoint(COMMUNITY + community + "/api/v1/package/") + .GetRequest(); + } + private static string SerializeFileData(string filePath) { return new FileData() diff --git a/ThunderstoreCLI/Utils/ActionUtils.cs b/ThunderstoreCLI/Utils/ActionUtils.cs index 5d11c8f..2e61fc8 100644 --- a/ThunderstoreCLI/Utils/ActionUtils.cs +++ b/ThunderstoreCLI/Utils/ActionUtils.cs @@ -4,27 +4,39 @@ public static class ActionUtils { public static void Retry(int maxTryCount, Action action) { - for (int i = 0; i < maxTryCount; i++) + for (int i = 1; i <= maxTryCount; i++) { try { action(); return; } - catch { } + catch + { + if (i == maxTryCount) + { + throw; + } + } } } public static async Task RetryAsync(int maxTryCount, Func action) { - for (int i = 0; i < maxTryCount; i++) + for (int i = 1; i <= maxTryCount; i++) { try { await action(); return; } - catch { } + catch + { + if (i == maxTryCount) + { + throw; + } + } } } } diff --git a/ThunderstoreCLI/Utils/DownloadCache.cs b/ThunderstoreCLI/Utils/DownloadCache.cs index dc3b936..c1ae67a 100644 --- a/ThunderstoreCLI/Utils/DownloadCache.cs +++ b/ThunderstoreCLI/Utils/DownloadCache.cs @@ -12,6 +12,10 @@ public sealed class DownloadCache public DownloadCache(string cacheDirectory) { CacheDirectory = cacheDirectory; + if (!Directory.Exists(CacheDirectory)) + { + Directory.CreateDirectory(cacheDirectory); + } } // Task instead of ValueTask here because these Tasks will be await'd multiple times (ValueTask does not allow that) diff --git a/ThunderstoreCLI/Utils/ModDependencyTree.cs b/ThunderstoreCLI/Utils/ModDependencyTree.cs index 0343736..8d421f8 100644 --- a/ThunderstoreCLI/Utils/ModDependencyTree.cs +++ b/ThunderstoreCLI/Utils/ModDependencyTree.cs @@ -18,7 +18,7 @@ public static IEnumerable Generate(Config config, HttpClient h string packagesJson; if (!File.Exists(cachePath) || new FileInfo(cachePath).LastWriteTime.AddMinutes(5) < DateTime.Now) { - var packageResponse = http.Send(config.Api.GetPackagesV1()); + var packageResponse = http.Send(config.Api.GetPackagesV1(sourceCommunity)); packageResponse.EnsureSuccessStatusCode(); using var responseReader = new StreamReader(packageResponse.Content.ReadAsStream()); packagesJson = responseReader.ReadToEnd(); diff --git a/ThunderstoreCLI/Utils/SteamUtils.cs b/ThunderstoreCLI/Utils/SteamUtils.cs index fab73e3..d7f703b 100644 --- a/ThunderstoreCLI/Utils/SteamUtils.cs +++ b/ThunderstoreCLI/Utils/SteamUtils.cs @@ -29,8 +29,12 @@ public static bool IsProtonGame(string steamAppId) throw new ArgumentException($"{steamAppId} is not installed!"); } - var source = PlatformOverrideSourceRegex.Match(File.ReadAllText(path)).Groups[1].Value; - return source switch + var source = PlatformOverrideSourceRegex.Match(File.ReadAllText(path)); + if (!source.Success) + { + return Directory.Exists(Path.Combine(Path.GetDirectoryName(path)!, "compatdata", steamAppId)); + } + return source.Groups[1].Value switch { "" => false, "linux" => false, From 999cd024531417baa6280130cd11d40d74e7a24d Mon Sep 17 00:00:00 2001 From: Aaron Robinson Date: Sat, 3 Dec 2022 02:39:57 -0600 Subject: [PATCH 25/25] Disable compiling installers when running tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 52e5b17..cf6a83b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: - name: Build run: dotnet build --configuration Release --no-restore - name: Run xUnit tests - run: dotnet test --collect:"XPlat Code Coverage" + run: dotnet test -p:EnableInstallers=false --collect:"XPlat Code Coverage" - name: Upload coverage to Codecov uses: codecov/codecov-action@v2 with: