LANCommander/LANCommander.SDK/Clients/SaveClient.cs

420 lines
17 KiB
C#

using LANCommander.SDK.Extensions;
using LANCommander.SDK.Helpers;
using LANCommander.SDK.Models;
using LANCommander.SDK.PowerShell;
using Microsoft.Extensions.Logging;
using SharpCompress.Common;
using SharpCompress.Readers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using LANCommander.SDK.Abstractions;
using LANCommander.SDK.Factories;
using LANCommander.SDK.Utilities;
using Action = System.Action;
// Some terms for this file since they're probably going to be needed in the future:
// Local path - The full path of the file/directory on the local disk. No variables used, just the raw path for current machine
// Actual path - The path where the entries should be extracted to, before expanding environemnt variables.
// Archive path - The path of where the entries are located in the ZIP
//
// Other notes:
// - Entries in the ZIP are separated by save path ID to avoid collision
namespace LANCommander.SDK.Services
{
public class SaveClient(
ApiRequestFactory apiRequestFactory,
ISettingsProvider settingsProvider,
PowerShellScriptFactory powerShellScriptFactory,
ILogger<SaveClient> logger)
{
public delegate void OnDownloadProgressHandler(DownloadProgressChangedEventArgs e);
public event OnDownloadProgressHandler OnDownloadProgress;
public delegate void OnDownloadCompleteHandler();
public event OnDownloadCompleteHandler OnDownloadComplete;
private async Task<FileInfo> DownloadAsync(Guid id, Action<DownloadProgressChangedEventArgs> progressHandler, Action completeHandler)
{
var destination = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Saves/{id}/Download")
.OnProgress(progressHandler)
.OnComplete(completeHandler)
.DownloadAsync(destination);
}
public async Task<FileInfo> DownloadLatestAsync(Guid gameId, Action<DownloadProgressChangedEventArgs> progressHandler, Action completeHandler)
{
var destination = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Saves/Game/{gameId}/Latest/Download")
.OnProgress(progressHandler)
.OnComplete(completeHandler)
.DownloadAsync(destination);
}
public async Task<IEnumerable<GameSave>> GetAsync(Guid gameId)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Saves/Game/{gameId}")
.GetAsync<IEnumerable<GameSave>>();
}
public async Task<GameSave> GetLatestAsync(Guid gameId)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Saves/Game/{gameId}/Latest")
.GetAsync<GameSave>();
}
public async Task DownloadAsync(string installDirectory, Guid gameId, Guid? saveId = null)
{
var manifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(installDirectory, gameId);
string tempFile;
string tempLocation = string.Empty;
if (manifest != null)
{
FileInfo destination;
if (!saveId.HasValue)
{
destination = await DownloadLatestAsync(manifest.Id, (changed) =>
{
OnDownloadProgress?.Invoke(changed);
}, () =>
{
OnDownloadComplete?.Invoke();
});
}
else
{
destination = await DownloadAsync(saveId.Value, (changed) =>
{
OnDownloadProgress?.Invoke(changed);
}, () =>
{
OnDownloadComplete?.Invoke();
});
}
if (!destination.Exists)
{
logger?.LogWarning("Save archive was not downloaded for game {GameId}", gameId);
return;
}
logger?.LogDebug("Save archive downloaded to {SaveTempLocation} for game {GameId}", destination, gameId);
tempFile = destination.FullName;
// Go into the archive and extract the files to the correct locations
try
{
tempLocation = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempLocation);
bool success = RetryHelper.RetryOnException(10, TimeSpan.FromMilliseconds(200), false, () =>
{
logger?.LogTrace("Extracting save archive to temporary location {TempPath}", tempLocation);
ExtractFilesFromZip(tempFile, tempLocation);
return true;
});
if (!success)
throw new ExtractionException("Could not extract the save archive. Is the file locked?");
manifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(tempLocation);
#region Move files
var tempLocationFilePath = "Files";
// Legacy support
if (!Directory.Exists(Path.Combine(tempLocation, tempLocationFilePath)))
tempLocationFilePath = "Saves";
foreach (var savePath in manifest.SavePaths.Where(sp => sp.Type == Enums.SavePathType.File && EnvironmentHelper.SupportsCurrentRuntime(sp.Platforms)))
{
var entries = GetFileSavePathEntries(savePath, installDirectory) ?? [];
logger?.LogTrace("Processing save path {SavePathId} with {EntryCount} entries", savePath.Id, entries.Count());
foreach (var entry in entries)
{
var entryPath = Path.Combine(tempLocation, tempLocationFilePath, savePath.Id.ToString(), entry.ArchivePath.Replace('/', Path.DirectorySeparatorChar));
var destinationPath = entry.ActualPath.ExpandEnvironmentVariables(installDirectory);
if (File.Exists(entryPath))
{
var destinationDirectory = Path.GetDirectoryName(destinationPath);
Directory.CreateDirectory(destinationDirectory);
// Handle individual files that were saved as an entry in the path
if (File.Exists(destinationPath))
File.Delete(destinationPath);
File.Move(entryPath, destinationPath);
logger?.LogTrace("Restored save file {ArchivePath} to {DestinationPath}", entry.ArchivePath, destinationPath);
}
else if (Directory.Exists(entryPath))
{
// Handle directories that were saved as an entry in the path
var entryFiles = Directory.GetFiles(entryPath, "*", SearchOption.AllDirectories);
foreach (var entryFile in entryFiles)
{
var fileDestination = entryFile.Replace(entryPath, destinationPath);
var destinationDirectory = Path.GetDirectoryName(fileDestination);
Directory.CreateDirectory(destinationDirectory);
if (File.Exists(fileDestination))
File.Delete(fileDestination);
File.Move(entryFile, fileDestination);
}
logger?.LogTrace("Restored save directory {ArchivePath} ({FileCount} files) to {DestinationPath}", entry.ArchivePath, entryFiles.Length, destinationPath);
}
else
{
logger?.LogWarning("Save entry {ArchivePath} not found in archive at {EntryPath}", entry.ArchivePath, entryPath);
}
}
}
#endregion
#region Handle registry importing
var registryImportFilePaths = Directory.GetFiles(tempLocation, "_registry*.reg");
var importer = new RegistryImportUtility();
foreach (var registryImportFilePath in registryImportFilePaths)
{
var registryImportFileContents = File.ReadAllText(registryImportFilePath);
var script = powerShellScriptFactory.Create(Enums.ScriptType.SaveDownload);
string adminArgument = string.Empty;
if (registryImportFileContents.Contains("HKEY_LOCAL_MACHINE"))
{
script.AsAdmin();
adminArgument = " -Verb RunAs";
}
script.UseInline($"Start-Process regedit.exe {adminArgument} -ArgumentList \"/s\", \"{registryImportFilePath}\"");
if (settingsProvider.CurrentValue.Debug.EnableScriptDebugging)
{
script.EnableDebug();
}
await script.ExecuteAsync<int>();
}
#endregion
// Clean up temp files
Directory.Delete(tempLocation, true);
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed to extract save files for game {GameId}", gameId);
}
finally
{
if (Directory.Exists(tempLocation))
Directory.Delete(tempLocation, true);
}
}
}
public async Task<Stream> PackAsync(string installDirectory, SDK.Models.Manifest.Game manifest)
{
using (var savePacker = new SavePacker(installDirectory))
{
if (manifest?.SavePaths.Any() ?? false)
savePacker.AddPaths(manifest.SavePaths.Where(sp => EnvironmentHelper.SupportsCurrentRuntime(sp.Platforms)));
await savePacker.AddManifestAsync(manifest);
return await savePacker.PackAsync();
}
}
public async Task<GameSave> UploadAsync(Stream stream, SDK.Models.Manifest.Game manifest)
{
return await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Saves/Game/{manifest.Id}/Upload?platform={EnvironmentHelper.GetCurrentRuntime()}")
.UploadAsync<GameSave>($"game-{manifest.Id}-save", stream);
}
public async Task UploadAsync(string installDirectory, Guid gameId)
{
using (var savePacker = new SavePacker(installDirectory))
{
var manifest = await ManifestHelper.ReadAsync<SDK.Models.Manifest.Game>(installDirectory, gameId);
if (manifest?.SavePaths?.Any() ?? false)
savePacker.AddPaths(manifest.SavePaths.Where(sp => EnvironmentHelper.SupportsCurrentRuntime(sp.Platforms)));
if (savePacker.HasEntries())
{
logger?.LogDebug("Packing {EntryCount} save entries for game {GameId}", savePacker.EntryCount, gameId);
await savePacker.AddManifestAsync(manifest);
var stream = await savePacker.PackAsync();
await UploadAsync(stream, manifest);
logger?.LogDebug("Save uploaded for game {GameId} ({Size} bytes)", gameId, stream.Length);
}
else
{
logger?.LogDebug("No save files found to upload for game {GameId}", gameId);
}
}
}
public async Task DeleteAsync(Guid id)
{
await apiRequestFactory
.Create()
.UseAuthenticationToken()
.UseVersioning()
.UseRoute($"/api/Saves/{id}")
.DeleteAsync<bool>();
}
public IEnumerable<SavePathEntry> GetFileSavePathEntries(SDK.Models.Manifest.SavePath savePath, string installDirectory)
{
IEnumerable<string> localPaths;
if (savePath.IsRegex)
{
var workingDirectory = GetLocalPath(savePath.WorkingDirectory, installDirectory);
var pattern = savePath.Path;
if (string.IsNullOrWhiteSpace(workingDirectory))
workingDirectory = installDirectory;
var regex = new Regex(pattern);
localPaths = Directory.GetFiles(workingDirectory, "*", SearchOption.AllDirectories)
.Where(p =>
{
var relativePath = p.Substring(workingDirectory.Length)
.TrimStart(Path.DirectorySeparatorChar)
.Replace('\\', '/');
return regex.IsMatch(relativePath);
})
.ToList();
}
else
{
var workingDirectory = GetLocalPath(savePath.WorkingDirectory, installDirectory);
var localPath = Path.Combine(workingDirectory, GetLocalPath(savePath.Path, installDirectory));
localPaths = new[] { localPath };
}
var entries = new List<SavePathEntry>();
foreach (var localPath in localPaths)
{
var actualPath = localPath.DeflateEnvironmentVariables(installDirectory);
var workingDirectory = savePath.WorkingDirectory.DeflateEnvironmentVariables(installDirectory);
var archivePath = actualPath.Replace(workingDirectory, "").TrimStart(Path.DirectorySeparatorChar);
entries.Add(new SavePathEntry
{
ArchivePath = archivePath.Replace(Path.DirectorySeparatorChar, '/'),
ActualPath = actualPath.Replace(Path.DirectorySeparatorChar, '/')
});
savePath.Entries = entries;
}
return entries;
}
public string GetLocalPath(string path, string installDirectory)
{
var localPath = path.ExpandEnvironmentVariables(installDirectory);
if (Path.DirectorySeparatorChar == '/')
localPath = localPath.Replace('\\', Path.DirectorySeparatorChar);
else
localPath = localPath.Replace('/', Path.DirectorySeparatorChar);
return localPath;
}
public string GetActualPath(string path, string installDirectory)
{
var actualPath = path.DeflateEnvironmentVariables(installDirectory);
if (Path.DirectorySeparatorChar == '\\')
actualPath = path.Replace('/', Path.DirectorySeparatorChar);
return actualPath;
}
public string GetArchivePath(string path, string workingDirectory, string installDirectory)
{
path = GetLocalPath(path, installDirectory);
workingDirectory = GetLocalPath(workingDirectory, installDirectory);
var archivePath = path.Replace(workingDirectory, "").Trim(Path.DirectorySeparatorChar);
if (Path.DirectorySeparatorChar == '\\')
archivePath = archivePath.Replace(Path.DirectorySeparatorChar, '/');
return archivePath;
}
private void ExtractFilesFromZip(string zipPath, string destination)
{
using (var fs = File.OpenRead(zipPath))
using (var ts = new TrackableStream(fs, fs.Length))
using (var reader = ReaderFactory.OpenReader(ts, new ReaderOptions()))
{
reader.WriteAllToDirectory(destination, new ExtractionOptions()
{
ExtractFullPath = true,
Overwrite = true
});
}
}
}
}