Skip to content

Commit

Permalink
Merge pull request #64 from thunderstore-io/mod-installation
Browse files Browse the repository at this point in the history
Mod installation
  • Loading branch information
Windows10CE authored Dec 3, 2022
2 parents 9d4a7f8 + 999cd02 commit a403dc7
Show file tree
Hide file tree
Showing 47 changed files with 2,690 additions and 130 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="PolySharp" Version="1.8.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Samboy063.Tomlet" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
Expand All @@ -20,6 +24,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="YamlDotNet" Version="12.0.2" />
</ItemGroup>

<ItemGroup>
Expand Down
6 changes: 3 additions & 3 deletions ThunderstoreCLI.Tests/Utils/Spinner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public async Task WhenTaskFails_ThrowsSpinnerException()
CreateTask(false)
});

await Assert.ThrowsAsync<SpinnerException>(async () => await spinner.Start());
await Assert.ThrowsAsync<SpinnerException>(async () => await spinner.Spin());
}

[Fact]
Expand All @@ -41,7 +41,7 @@ public async Task WhenReceivesSingleTask_ItJustWorks()
CreateTask(true)
});

await spinner.Start();
await spinner.Spin();
}

[Fact]
Expand All @@ -53,6 +53,6 @@ public async Task WhenReceivesMultipleTasks_ItJustWorks()
CreateTask(true)
});

await spinner.Start();
await spinner.Spin();
}
}
35 changes: 34 additions & 1 deletion ThunderstoreCLI/API/ApiHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthenticationHeaderValue>(() =>
{
if (string.IsNullOrEmpty(Config.AuthConfig.AuthToken))
Expand All @@ -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)
{
Expand Down Expand Up @@ -73,6 +74,38 @@ public HttpRequestMessage AbortUploadMedia(string uuid)
.GetRequest();
}

public HttpRequestMessage GetPackageMetadata(string author, string name)
{
return BaseRequestBuilder
.StartNew()
.WithEndpoint(EXPERIMENTAL + $"package/{author}/{name}/")
.GetRequest();
}

public HttpRequestMessage GetPackageVersionMetadata(string author, string name, string version)
{
return BaseRequestBuilder
.StartNew()
.WithEndpoint(EXPERIMENTAL + $"package/{author}/{name}/{version}/")
.GetRequest();
}

public HttpRequestMessage GetPackagesV1()
{
return BaseRequestBuilder
.StartNew()
.WithEndpoint(V1 + "package/")
.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()
Expand Down
1 change: 0 additions & 1 deletion ThunderstoreCLI/Commands/BuildCommand.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.IO.Compression;
using System.Text;
using Newtonsoft.Json;
using ThunderstoreCLI.Configuration;
using ThunderstoreCLI.Models;
using ThunderstoreCLI.Utils;
Expand Down
40 changes: 40 additions & 0 deletions ThunderstoreCLI/Commands/ImportGameCommand.cs
Original file line number Diff line number Diff line change
@@ -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");
}

var collection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig);
collection.List.Add(def);
collection.Write();

Write.Success($"Successfully imported {def.Name} ({def.Identifier}) with install folder \"{def.InstallDirectory}\"");

return 0;
}
}
2 changes: 1 addition & 1 deletion ThunderstoreCLI/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
174 changes: 174 additions & 0 deletions ThunderstoreCLI/Commands/InstallCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using ThunderstoreCLI.Configuration;
using ThunderstoreCLI.Game;
using ThunderstoreCLI.Models;
using ThunderstoreCLI.Utils;

namespace ThunderstoreCLI.Commands;

public static partial class InstallCommand
{
// will match either ab-cd or ab-cd-123.456.7890
internal static Regex FullPackageNameRegex = new Regex(@"^(?<fullname>(?<namespace>[\w-\.]+)-(?<name>\w+))(?:|-(?<version>\d+\.\d+\.\d+))$");

public static async Task<int> Run(Config config)
{
var defCollection = GameDefinitionCollection.FromDirectory(config.GeneralConfig.TcliConfig);
var defs = defCollection.List;
GameDefinition? def = defs.FirstOrDefault(x => x.Identifier == config.ModManagementConfig.GameIdentifer);
if (def == null)
{
Write.ErrorExit($"Not configured for the game: {config.ModManagementConfig.GameIdentifer}");
return 1;
}

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!;

HttpClient http = new();

int returnCode;
Match packageMatch = FullPackageNameRegex.Match(package);
if (File.Exists(package))
{
returnCode = await InstallZip(config, http, def, profile, package, null, null);
}
else if (packageMatch.Success)
{
returnCode = await InstallFromRepository(config, http, def, profile, packageMatch);
}
else
{
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.Write();

return returnCode;
}

private static async Task<int> InstallFromRepository(Config config, HttpClient http, GameDefinition game, ModProfile profile, Match packageMatch)
{
PackageVersionData? versionData = null;
Write.Light($"Downloading main package: {packageMatch.Groups["fullname"].Value}");

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(ns.Value, name.Value, version.Value));
versionResponse.EnsureSuccessStatusCode();
versionData = (await PackageVersionData.DeserializeAsync(await versionResponse.Content.ReadAsStreamAsync()))!;
}
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!, packageData!.CommunityListings!.First().Community);
return returnCode;
}

private static async Task<int> 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!");
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/");

manifest.Namespace ??= backupNamespace;

var dependenciesToInstall = ModDependencyTree.Generate(config, http, manifest, sourceCommunity)
.Where(dependency => !profile.InstalledModVersions.ContainsKey(dependency.Fullname!))
.ToArray();

if (dependenciesToInstall.Length > 0)
{
var totalSize = MiscUtils.GetSizeString(dependenciesToInstall.Select(d => d.Versions![0].FileSize).Sum());
Write.Light($"Total estimated download size: ");

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))
{
var packageVersion = package.Versions![0];
int returnCode = RunInstaller(game, profile, tempZipPath, package.Owner);
if (returnCode == 0)
{
Write.Success($"Installed mod: {package.Fullname}-{packageVersion.VersionNumber}");
}
else
{
Write.Error($"Failed to install mod: {package.Fullname}-{packageVersion.VersionNumber}");
return returnCode;
}
profile.InstalledModVersions[package.Fullname!] = new PackageManifestV1(package, packageVersion);
}
}

var exitCode = RunInstaller(game, profile, zipPath, backupNamespace);
if (exitCode == 0)
{
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: 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);

ProcessStartInfo installerInfo = new(bepinexInstallerPath)
{
ArgumentList =
{
"install",
game.InstallDirectory,
profile.ProfileDirectory,
zipPath
},
RedirectStandardError = true
};
if (backupNamespace != null)
{
installerInfo.ArgumentList.Add("--namespace-backup");
installerInfo.ArgumentList.Add(backupNamespace);
}

var installerProcess = Process.Start(installerInfo)!;
installerProcess.WaitForExit();

string errors = installerProcess.StandardError.ReadToEnd();
if (!string.IsNullOrWhiteSpace(errors))
{
Write.Error(errors);
}

return installerProcess.ExitCode;
}
}
13 changes: 2 additions & 11 deletions ThunderstoreCLI/Commands/PublishCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,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)
{
Expand Down Expand Up @@ -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;
Expand Down
Loading

0 comments on commit a403dc7

Please sign in to comment.