Refactor GameSaveService into GameSaveManager and SaveController. Update Playnite addon authentication dialogs to use new client.

pull/32/head
Pat Hartl 2023-11-10 01:32:30 -06:00
parent 39f2d4b212
commit 73b542856a
9 changed files with 201 additions and 153 deletions

View File

@ -42,6 +42,9 @@
</Reference> </Reference>
<Reference Include="PresentationCore" /> <Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" /> <Reference Include="PresentationFramework" />
<Reference Include="SharpCompress, Version=0.34.1.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\SharpCompress.0.34.1\lib\net462\SharpCompress.dll</HintPath>
</Reference>
<Reference Include="System" /> <Reference Include="System" />
<Reference Include="System.Buffers, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL"> <Reference Include="System.Buffers, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll</HintPath> <HintPath>..\packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll</HintPath>
@ -85,10 +88,13 @@
<Reference Include="System.Net.Http" /> <Reference Include="System.Net.Http" />
<Reference Include="System.Xml" /> <Reference Include="System.Xml" />
<Reference Include="WindowsBase" /> <Reference Include="WindowsBase" />
<Reference Include="ZstdSharp, Version=0.7.2.0, Culture=neutral, PublicKeyToken=8d151af33a4ad5cf, processorArchitecture=MSIL">
<HintPath>..\packages\ZstdSharp.Port.0.7.2\lib\net461\ZstdSharp.dll</HintPath>
</Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Extensions\MultiplayerInfoExtensions.cs" /> <Compile Include="Extensions\MultiplayerInfoExtensions.cs" />
<Compile Include="Services\GameSaveService.cs" /> <Compile Include="SaveController.cs" />
<Compile Include="UninstallController.cs" /> <Compile Include="UninstallController.cs" />
<Compile Include="InstallController.cs" /> <Compile Include="InstallController.cs" />
<Compile Include="LANCommanderLibraryPlugin.cs" /> <Compile Include="LANCommanderLibraryPlugin.cs" />

View File

@ -1,5 +1,4 @@
using LANCommander.PlaynitePlugin.Extensions; using LANCommander.PlaynitePlugin.Extensions;
using LANCommander.PlaynitePlugin.Services;
using Playnite.SDK; using Playnite.SDK;
using Playnite.SDK.Events; using Playnite.SDK.Events;
using Playnite.SDK.Models; using Playnite.SDK.Models;
@ -22,7 +21,7 @@ namespace LANCommander.PlaynitePlugin
public static readonly ILogger Logger = LogManager.GetLogger(); public static readonly ILogger Logger = LogManager.GetLogger();
internal LANCommanderSettingsViewModel Settings { get; set; } internal LANCommanderSettingsViewModel Settings { get; set; }
internal LANCommander.SDK.Client LANCommanderClient { get; set; } internal LANCommander.SDK.Client LANCommanderClient { get; set; }
internal GameSaveService GameSaveService { get; set; } internal LANCommanderSaveController SaveController { get; set; }
public override Guid Id { get; } = Guid.Parse("48e1bac7-e0a0-45d7-ba83-36f5e9e959fc"); public override Guid Id { get; } = Guid.Parse("48e1bac7-e0a0-45d7-ba83-36f5e9e959fc");
public override string Name => "LANCommander"; public override string Name => "LANCommander";
@ -333,12 +332,12 @@ namespace LANCommander.PlaynitePlugin
public override void OnGameStarting(OnGameStartingEventArgs args) public override void OnGameStarting(OnGameStartingEventArgs args)
{ {
GameSaveService.DownloadSave(args.Game); SaveController.Download(args.Game);
} }
public override void OnGameStopped(OnGameStoppedEventArgs args) public override void OnGameStopped(OnGameStoppedEventArgs args)
{ {
GameSaveService.UploadSave(args.Game); SaveController.Upload(args.Game);
} }
public override IEnumerable<TopPanelItem> GetTopPanelItems() public override IEnumerable<TopPanelItem> GetTopPanelItems()

View File

@ -0,0 +1,61 @@
using LANCommander.SDK;
using Playnite.SDK;
using Playnite.SDK.Models;
using Playnite.SDK.Plugins;
namespace LANCommander.PlaynitePlugin
{
public class LANCommanderSaveController : ControllerBase
{
private static readonly ILogger Logger;
private LANCommanderLibraryPlugin Plugin;
public LANCommanderSaveController(LANCommanderLibraryPlugin plugin, Game game) : base(game)
{
Name = "Download save using LANCommander";
Plugin = plugin;
}
public void Download(Game game)
{
if (game != null)
{
Plugin.PlayniteApi.Dialogs.ActivateGlobalProgress(progress =>
{
progress.ProgressMaxValue = 100;
progress.CurrentProgressValue = 0;
var saveManager = new GameSaveManager(Plugin.LANCommanderClient);
saveManager.OnDownloadProgress += (downloadProgress) =>
{
progress.CurrentProgressValue = downloadProgress.ProgressPercentage;
};
saveManager.OnDownloadComplete += (downloadComplete) =>
{
progress.CurrentProgressValue = 100;
};
saveManager.Download(game.InstallDirectory);
// Lock the thread until the download is done
while (progress.CurrentProgressValue != 100) { }
},
new GlobalProgressOptions("Downloading latest save...")
{
IsIndeterminate = false,
Cancelable = false
});
}
}
public void Upload(Game game)
{
var saveManager = new GameSaveManager(Plugin.LANCommanderClient);
saveManager.Upload(game.InstallDirectory);
}
}
}

View File

@ -100,24 +100,16 @@ namespace LANCommander.PlaynitePlugin.Views
LoginButton.Content = "Logging in..."; LoginButton.Content = "Logging in...";
})); }));
if (Plugin.LANCommander == null || Plugin.LANCommander.Client == null) if (Plugin.LANCommanderClient == null)
Plugin.LANCommander = new LANCommanderClient(Context.ServerAddress); Plugin.LANCommanderClient = new LANCommander.SDK.Client(Context.ServerAddress);
else
Plugin.LANCommander.Client.BaseUrl = new Uri(Context.ServerAddress);
var response = await Plugin.LANCommander.AuthenticateAsync(Context.UserName, Context.Password); var response = await Plugin.LANCommanderClient.AuthenticateAsync(Context.UserName, Context.Password);
Plugin.Settings.ServerAddress = Context.ServerAddress; Plugin.Settings.ServerAddress = Context.ServerAddress;
Plugin.Settings.AccessToken = response.AccessToken; Plugin.Settings.AccessToken = response.AccessToken;
Plugin.Settings.RefreshToken = response.RefreshToken; Plugin.Settings.RefreshToken = response.RefreshToken;
Plugin.LANCommander.Token = new AuthToken() var profile = Plugin.LANCommanderClient.GetProfile();
{
AccessToken = response.AccessToken,
RefreshToken = response.RefreshToken,
};
var profile = Plugin.LANCommander.GetProfile();
Plugin.Settings.PlayerName = String.IsNullOrWhiteSpace(profile.Alias) ? profile.UserName : profile.Alias; Plugin.Settings.PlayerName = String.IsNullOrWhiteSpace(profile.Alias) ? profile.UserName : profile.Alias;
@ -148,24 +140,16 @@ namespace LANCommander.PlaynitePlugin.Views
RegisterButton.IsEnabled = false; RegisterButton.IsEnabled = false;
RegisterButton.Content = "Working..."; RegisterButton.Content = "Working...";
if (Plugin.LANCommander == null || Plugin.LANCommander.Client == null) if (Plugin.LANCommanderClient == null)
Plugin.LANCommander = new LANCommanderClient(Context.ServerAddress); Plugin.LANCommanderClient = new LANCommander.SDK.Client(Context.ServerAddress);
else
Plugin.LANCommander.Client.BaseUrl = new Uri(Context.ServerAddress);
var response = await Plugin.LANCommander.RegisterAsync(Context.UserName, Context.Password); var response = await Plugin.LANCommanderClient.RegisterAsync(Context.UserName, Context.Password);
Plugin.Settings.ServerAddress = Context.ServerAddress; Plugin.Settings.ServerAddress = Context.ServerAddress;
Plugin.Settings.AccessToken = response.AccessToken; Plugin.Settings.AccessToken = response.AccessToken;
Plugin.Settings.RefreshToken = response.RefreshToken; Plugin.Settings.RefreshToken = response.RefreshToken;
Plugin.Settings.PlayerName = Context.UserName; Plugin.Settings.PlayerName = Context.UserName;
Plugin.LANCommander.Token = new AuthToken()
{
AccessToken = response.AccessToken,
RefreshToken = response.RefreshToken,
};
Context.Password = String.Empty; Context.Password = String.Empty;
Plugin.SavePluginSettings(Plugin.Settings); Plugin.SavePluginSettings(Plugin.Settings);

View File

@ -47,7 +47,7 @@ namespace LANCommander.PlaynitePlugin
RefreshToken = Settings.RefreshToken, RefreshToken = Settings.RefreshToken,
}; };
var task = Task.Run(() => Plugin.LANCommander.ValidateToken(token)) var task = Task.Run(() => Plugin.LANCommanderClient.ValidateToken(token))
.ContinueWith(antecedent => .ContinueWith(antecedent =>
{ {
try try
@ -90,7 +90,7 @@ namespace LANCommander.PlaynitePlugin
{ {
Plugin.Settings.AccessToken = String.Empty; Plugin.Settings.AccessToken = String.Empty;
Plugin.Settings.RefreshToken = String.Empty; Plugin.Settings.RefreshToken = String.Empty;
Plugin.LANCommander.Token = null; Plugin.LANCommanderClient.UseToken(null);
Plugin.SavePluginSettings(Plugin.Settings); Plugin.SavePluginSettings(Plugin.Settings);

View File

@ -107,7 +107,7 @@ namespace LANCommander.SDK
} }
} }
public async Task<AuthResponse> RegisterAsync(string username, string password) public async Task<AuthToken> RegisterAsync(string username, string password)
{ {
var response = await ApiClient.ExecuteAsync<AuthResponse>(new RestRequest("/api/auth/register", Method.POST).AddJsonBody(new AuthRequest() var response = await ApiClient.ExecuteAsync<AuthResponse>(new RestRequest("/api/auth/register", Method.POST).AddJsonBody(new AuthRequest()
{ {
@ -118,7 +118,14 @@ namespace LANCommander.SDK
switch (response.StatusCode) switch (response.StatusCode)
{ {
case HttpStatusCode.OK: case HttpStatusCode.OK:
return response.Data; Token = new AuthToken
{
AccessToken = response.Data.AccessToken,
RefreshToken = response.Data.RefreshToken,
Expiration = response.Data.Expiration
};
return Token;
case HttpStatusCode.BadRequest: case HttpStatusCode.BadRequest:
case HttpStatusCode.Forbidden: case HttpStatusCode.Forbidden:
@ -137,7 +144,7 @@ namespace LANCommander.SDK
return response.StatusCode == HttpStatusCode.OK; return response.StatusCode == HttpStatusCode.OK;
} }
public AuthResponse RefreshToken(AuthToken token) public AuthToken RefreshToken(AuthToken token)
{ {
Logger.LogTrace("Refreshing token..."); Logger.LogTrace("Refreshing token...");
@ -149,7 +156,14 @@ namespace LANCommander.SDK
if (response.StatusCode != HttpStatusCode.OK) if (response.StatusCode != HttpStatusCode.OK)
throw new WebException(response.ErrorMessage); throw new WebException(response.ErrorMessage);
return response.Data; Token = new AuthToken
{
AccessToken = response.Data.AccessToken,
RefreshToken = response.Data.RefreshToken,
Expiration = response.Data.Expiration
};
return Token;
} }
public bool ValidateToken() public bool ValidateToken()

View File

@ -1,64 +1,55 @@
using LANCommander.SDK; using LANCommander.SDK;
using Playnite.SDK; using LANCommander.SDK.Helpers;
using Playnite.SDK.Models; using LANCommander.SDK.Models;
using SharpCompress.Archives; using SharpCompress.Archives;
using SharpCompress.Archives.Zip; using SharpCompress.Archives.Zip;
using SharpCompress.Common; using SharpCompress.Common;
using SharpCompress.Readers; using SharpCompress.Readers;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net;
using System.Text; using System.Text;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions; using YamlDotNet.Serialization.NamingConventions;
namespace LANCommander.PlaynitePlugin.Services namespace LANCommander.SDK
{ {
internal class GameSaveService public class GameSaveManager
{ {
private readonly LANCommander.SDK.Client LANCommander; private readonly Client Client;
private readonly IPlayniteAPI PlayniteApi;
internal GameSaveService(LANCommander.SDK.Client lanCommander, IPlayniteAPI playniteApi) public delegate void OnDownloadProgressHandler(DownloadProgressChangedEventArgs e);
public event OnDownloadProgressHandler OnDownloadProgress;
public delegate void OnDownloadCompleteHandler(AsyncCompletedEventArgs e);
public event OnDownloadCompleteHandler OnDownloadComplete;
public GameSaveManager(Client client)
{ {
LANCommander = lanCommander; Client = client;
PlayniteApi = playniteApi;
} }
internal void DownloadSave(Game game) public void Download(string installDirectory)
{ {
var manifest = ManifestHelper.Read(installDirectory);
string tempFile = String.Empty; string tempFile = String.Empty;
if (game != null) if (manifest != null)
{ {
PlayniteApi.Dialogs.ActivateGlobalProgress(progress => var destination = Client.DownloadLatestSave(manifest.Id, (changed) =>
{ {
progress.ProgressMaxValue = 100; OnDownloadProgress?.Invoke(changed);
progress.CurrentProgressValue = 0; }, (complete) =>
var destination = LANCommander.DownloadLatestSave(Guid.Parse(game.GameId), (changed) =>
{
progress.CurrentProgressValue = changed.ProgressPercentage;
}, (complete) =>
{
progress.CurrentProgressValue = 100;
});
// Lock the thread until download is done
while (progress.CurrentProgressValue != 100)
{
}
tempFile = destination;
},
new GlobalProgressOptions("Downloading latest save...")
{ {
IsIndeterminate = false, OnDownloadComplete?.Invoke(complete);
Cancelable = false
}); });
tempFile = destination;
// Go into the archive and extract the files to the correct locations // Go into the archive and extract the files to the correct locations
try try
{ {
@ -72,10 +63,6 @@ namespace LANCommander.PlaynitePlugin.Services
.WithNamingConvention(new PascalCaseNamingConvention()) .WithNamingConvention(new PascalCaseNamingConvention())
.Build(); .Build();
var manifestContents = File.ReadAllText(Path.Combine(tempLocation, "_manifest.yml"));
var manifest = deserializer.Deserialize<GameManifest>(manifestContents);
#region Move files #region Move files
foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == "File")) foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == "File"))
{ {
@ -84,7 +71,7 @@ namespace LANCommander.PlaynitePlugin.Services
var tempSavePathFile = Path.Combine(tempSavePath, savePath.Path.Replace('/', '\\').Replace("{InstallDir}\\", "")); var tempSavePathFile = Path.Combine(tempSavePath, savePath.Path.Replace('/', '\\').Replace("{InstallDir}\\", ""));
var destination = Environment.ExpandEnvironmentVariables(savePath.Path.Replace('/', '\\').Replace("{InstallDir}", game.InstallDirectory)); destination = Environment.ExpandEnvironmentVariables(savePath.Path.Replace('/', '\\').Replace("{InstallDir}", installDirectory));
if (File.Exists(tempSavePathFile)) if (File.Exists(tempSavePathFile))
{ {
@ -105,7 +92,7 @@ namespace LANCommander.PlaynitePlugin.Services
if (inInstallDir) if (inInstallDir)
{ {
// Files are in the game's install directory. Move them there from the save path. // Files are in the game's install directory. Move them there from the save path.
destination = file.Replace(tempSavePath, savePath.Path.Replace('/', '\\').TrimEnd('\\').Replace("{InstallDir}", game.InstallDirectory)); destination = file.Replace(tempSavePath, savePath.Path.Replace('/', '\\').TrimEnd('\\').Replace("{InstallDir}", installDirectory));
if (File.Exists(destination)) if (File.Exists(destination))
File.Delete(destination); File.Delete(destination);
@ -153,97 +140,89 @@ namespace LANCommander.PlaynitePlugin.Services
} }
} }
internal void UploadSave(Game game) public void Upload(string installDirectory)
{ {
var manifestPath = Path.Combine(game.InstallDirectory, "_manifest.yml"); var manifest = ManifestHelper.Read(installDirectory);
if (File.Exists(manifestPath)) var temp = Path.GetTempFileName();
if (manifest.SavePaths != null && manifest.SavePaths.Count() > 0)
{ {
var deserializer = new DeserializerBuilder() using (var archive = ZipArchive.Create())
.WithNamingConvention(new PascalCaseNamingConvention())
.Build();
var manifest = deserializer.Deserialize<GameManifest>(File.ReadAllText(manifestPath));
var temp = Path.GetTempFileName();
if (manifest.SavePaths != null && manifest.SavePaths.Count() > 0)
{ {
using (var archive = ZipArchive.Create()) archive.DeflateCompressionLevel = SharpCompress.Compressors.Deflate.CompressionLevel.BestCompression;
#region Add files from defined paths
foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == "File"))
{ {
archive.DeflateCompressionLevel = SharpCompress.Compressors.Deflate.CompressionLevel.BestCompression; var localPath = Environment.ExpandEnvironmentVariables(savePath.Path.Replace('/', '\\').Replace("{InstallDir}", installDirectory));
#region Add files from defined paths if (Directory.Exists(localPath))
foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == "File"))
{ {
var localPath = Environment.ExpandEnvironmentVariables(savePath.Path.Replace('/', '\\').Replace("{InstallDir}", game.InstallDirectory)); AddDirectoryToZip(archive, localPath, localPath, savePath.Id);
if (Directory.Exists(localPath))
{
AddDirectoryToZip(archive, localPath, localPath, savePath.Id);
}
else if (File.Exists(localPath))
{
archive.AddEntry(Path.Combine(savePath.Id.ToString(), savePath.Path.Replace("{InstallDir}/", "")), localPath);
}
} }
#endregion else if (File.Exists(localPath))
#region Add files from defined paths
foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == "File"))
{ {
var localPath = Environment.ExpandEnvironmentVariables(savePath.Path.Replace('/', '\\').Replace("{InstallDir}", game.InstallDirectory)); archive.AddEntry(Path.Combine(savePath.Id.ToString(), savePath.Path.Replace("{InstallDir}/", "")), localPath);
if (Directory.Exists(localPath))
{
AddDirectoryToZip(archive, localPath, localPath, savePath.Id);
}
else if (File.Exists(localPath))
{
archive.AddEntry(Path.Combine(savePath.Id.ToString(), savePath.Path.Replace("{InstallDir}/", "")), localPath);
}
} }
#endregion }
#endregion
#region Export registry keys #region Add files from defined paths
if (manifest.SavePaths.Any(sp => sp.Type == "Registry")) foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == "File"))
{
var localPath = Environment.ExpandEnvironmentVariables(savePath.Path.Replace('/', '\\').Replace("{InstallDir}", installDirectory));
if (Directory.Exists(localPath))
{ {
List<string> tempRegFiles = new List<string>(); AddDirectoryToZip(archive, localPath, localPath, savePath.Id);
var exportCommand = new StringBuilder();
foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == "Registry"))
{
var tempRegFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".reg");
exportCommand.AppendLine($"reg.exe export \"{savePath.Path.Replace(":\\", "\\")}\" \"{tempRegFile}\"");
tempRegFiles.Add(tempRegFile);
}
PowerShellRuntime.RunCommand(exportCommand.ToString());
var exportFile = new StringBuilder();
foreach (var tempRegFile in tempRegFiles)
{
exportFile.AppendLine(File.ReadAllText(tempRegFile));
File.Delete(tempRegFile);
}
archive.AddEntry("_registry.reg", new MemoryStream(Encoding.UTF8.GetBytes(exportFile.ToString())), true);
} }
#endregion else if (File.Exists(localPath))
archive.AddEntry("_manifest.yml", manifestPath);
using (var ms = new MemoryStream())
{ {
archive.SaveTo(ms); archive.AddEntry(Path.Combine(savePath.Id.ToString(), savePath.Path.Replace("{InstallDir}/", "")), localPath);
ms.Seek(0, SeekOrigin.Begin);
var save = LANCommander.UploadSave(game.GameId, ms.ToArray());
} }
} }
#endregion
#region Export registry keys
if (manifest.SavePaths.Any(sp => sp.Type == "Registry"))
{
List<string> tempRegFiles = new List<string>();
var exportCommand = new StringBuilder();
foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == "Registry"))
{
var tempRegFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".reg");
exportCommand.AppendLine($"reg.exe export \"{savePath.Path.Replace(":\\", "\\")}\" \"{tempRegFile}\"");
tempRegFiles.Add(tempRegFile);
}
PowerShellRuntime.RunCommand(exportCommand.ToString());
var exportFile = new StringBuilder();
foreach (var tempRegFile in tempRegFiles)
{
exportFile.AppendLine(File.ReadAllText(tempRegFile));
File.Delete(tempRegFile);
}
archive.AddEntry("_registry.reg", new MemoryStream(Encoding.UTF8.GetBytes(exportFile.ToString())), true);
}
#endregion
archive.AddEntry("_manifest.yml", ManifestHelper.GetPath(installDirectory));
using (var ms = new MemoryStream())
{
archive.SaveTo(ms);
ms.Seek(0, SeekOrigin.Begin);
var save = Client.UploadSave(manifest.Id.ToString(), ms.ToArray());
}
} }
} }
} }

View File

@ -16,7 +16,7 @@ namespace LANCommander.SDK.Helpers
public static GameManifest Read(string installDirectory) public static GameManifest Read(string installDirectory)
{ {
var source = Path.Combine(installDirectory, ManifestFilename); var source = GetPath(installDirectory);
var yaml = File.ReadAllText(source); var yaml = File.ReadAllText(source);
var deserializer = new DeserializerBuilder() var deserializer = new DeserializerBuilder()
@ -32,7 +32,7 @@ namespace LANCommander.SDK.Helpers
public static void Write(GameManifest manifest, string installDirectory) public static void Write(GameManifest manifest, string installDirectory)
{ {
var destination = Path.Combine(installDirectory, ManifestFilename); var destination = GetPath(installDirectory);
Logger.LogTrace("Attempting to write manifest to path {Destination}", destination); Logger.LogTrace("Attempting to write manifest to path {Destination}", destination);
@ -48,5 +48,10 @@ namespace LANCommander.SDK.Helpers
File.WriteAllText(destination, yaml); File.WriteAllText(destination, yaml);
} }
public static string GetPath(string installDirectory)
{
return Path.Combine(installDirectory, ManifestFilename);
}
} }
} }

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework> <TargetFramework>netstandard2.0</TargetFramework>
@ -8,7 +8,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
<PackageReference Include="RestSharp" Version="106.15.0" /> <PackageReference Include="RestSharp" Version="106.15.0" />
<PackageReference Include="SharpCompress" Version="0.34.1" /> <PackageReference Include="SharpCompress" Version="0.34.1" />
<PackageReference Include="YamlDotNet" Version="13.7.1" /> <PackageReference Include="YamlDotNet" Version="13.3.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>