mirror of
https://github.com/simplysanatori/KartFrame.git
synced 2026-10-22 08:01:37 +02:00
175 lines
10 KiB
C#
175 lines
10 KiB
C#
using System.IO.Compression;
|
|
using WiiCompiled.Setup.Common;
|
|
|
|
namespace WiiCompiled.Setup.Windows;
|
|
|
|
internal static class RetroRewindDownload
|
|
{
|
|
private const string Server = "https://update.rwfc.net/RetroRewind/";
|
|
internal const long MaximumDownloadBytes = 4L * 1024 * 1024 * 1024;
|
|
internal const long MaximumExtractedBytes = 8L * 1024 * 1024 * 1024;
|
|
internal sealed record Update(Version Version, string Url);
|
|
|
|
internal static List<Update> ParseUpdates(string manifest) => manifest.Split('\n')
|
|
.Select(line => line.Trim().Split(' ', 4, StringSplitOptions.RemoveEmptyEntries))
|
|
.Where(parts => parts.Length == 4 && Version.TryParse(parts[0], out _))
|
|
.Select(parts => new Update(Version.Parse(parts[0]), parts[1].Replace(
|
|
"http://update.rwfc.net:8000/", "https://update.rwfc.net/")))
|
|
.OrderBy(update => update.Version).ToList();
|
|
|
|
public static async Task<string> EnsureLatestAsync(string staging, string? existing,
|
|
IInstallReporter reporter, CancellationToken cancellationToken)
|
|
{
|
|
using var client = CreateClient();
|
|
reporter.Progress(InstallStages.Validate, "Checking the latest official Retro Rewind version...", 2);
|
|
var updates = ParseUpdates(await client.GetStringAsync(Server + "RetroRewindVersion.txt", cancellationToken));
|
|
if (updates.Count == 0) throw new InvalidDataException("The Retro Rewind version manifest is empty.");
|
|
var versionFile = existing is null ? null : Path.Combine(existing, "version.txt");
|
|
if (versionFile is not null && File.Exists(versionFile) &&
|
|
Version.TryParse(File.ReadAllText(versionFile).Trim(), out var current) && current >= updates[^1].Version)
|
|
{
|
|
reporter.Diagnostic($"Retro Rewind {current} is up to date; no pack download needed.");
|
|
return existing!;
|
|
}
|
|
var downloaded = await DownloadAsync(staging, reporter, cancellationToken);
|
|
// Custom Wheel Wizard patches survive replacing an outdated pack.
|
|
var oldPatches = existing is null ? null : Path.Combine(existing, "Patches");
|
|
if (oldPatches is not null && Directory.Exists(oldPatches))
|
|
foreach (var file in Directory.EnumerateFiles(oldPatches, "*", SearchOption.AllDirectories))
|
|
{
|
|
var destination = Path.Combine(downloaded, "Patches", Path.GetRelativePath(oldPatches, file));
|
|
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
|
File.Copy(file, destination, overwrite: true);
|
|
}
|
|
return downloaded;
|
|
}
|
|
|
|
private static HttpClient CreateClient()
|
|
{
|
|
var client = new HttpClient { Timeout = TimeSpan.FromMinutes(30) };
|
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("WheelWizard/2.5.4");
|
|
return client;
|
|
}
|
|
|
|
public static async Task<string> DownloadAsync(string staging, IInstallReporter reporter,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var client = CreateClient();
|
|
// The official service rejects clients without Wheel Wizard's user agent.
|
|
reporter.Progress(InstallStages.Validate, "Finding the latest Retro Rewind release...", 2);
|
|
var address = (await client.GetStringAsync(
|
|
"https://update.rwfc.net/RetroRewind/RetroRewindInstall.txt", cancellationToken)).Trim();
|
|
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) || uri.Scheme != "https")
|
|
throw new InvalidDataException("The Retro Rewind service returned an invalid download URL.");
|
|
var archive = Path.Combine(staging, "RetroRewind-download.zip");
|
|
reporter.Progress(InstallStages.Validate, "Downloading Retro Rewind...", 2);
|
|
using (var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
|
|
{
|
|
response.EnsureSuccessStatusCode();
|
|
await SaveDownloadAsync(response, archive, cancellationToken);
|
|
}
|
|
reporter.Progress(InstallStages.Validate, "Extracting Retro Rewind...", 2);
|
|
var extraction = Path.Combine(staging, "retro-download");
|
|
await ExtractPackAsync(archive, extraction, cancellationToken);
|
|
var source = RetroRewindSource.ResolveRetroRewind6(extraction);
|
|
// The full ZIP can lag behind incremental releases. Apply every newer official patch
|
|
// before taking compile-input snapshots, just as Wheel Wizard does.
|
|
for (var attempt = 0; attempt < 3; attempt++)
|
|
{
|
|
var updates = ParseUpdates(await client.GetStringAsync(Server + "RetroRewindVersion.txt", cancellationToken));
|
|
if (updates.Count == 0) throw new InvalidDataException("The Retro Rewind version manifest is empty.");
|
|
var current = Version.Parse(File.ReadAllText(Path.Combine(source, "version.txt")).Trim());
|
|
var pending = updates.Where(update => update.Version > current).ToList();
|
|
if (pending.Count == 0) break;
|
|
var deletions = await client.GetStringAsync(Server + "RetroRewindDelete.txt", cancellationToken);
|
|
foreach (var update in pending)
|
|
{
|
|
reporter.Progress(InstallStages.Validate, $"Installing Retro Rewind update {update.Version}...", 2);
|
|
foreach (var line in deletions.Split('\n'))
|
|
{
|
|
var parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length != 2 || !Version.TryParse(parts[0], out var version) || version <= current || version > update.Version) continue;
|
|
var target = Path.GetFullPath(Path.Combine(extraction, parts[1].TrimStart('/', '\\')));
|
|
if (!target.StartsWith(Path.GetFullPath(extraction) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidDataException("Invalid path in the Retro Rewind deletion manifest.");
|
|
if (File.Exists(target)) File.Delete(target);
|
|
else if (Directory.Exists(target)) Directory.Delete(target, recursive: true);
|
|
}
|
|
if (!Uri.TryCreate(update.Url, UriKind.Absolute, out var patchUri) || patchUri.Scheme != "https")
|
|
throw new InvalidDataException("Invalid Retro Rewind update URL.");
|
|
using var response = await client.GetAsync(patchUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
response.EnsureSuccessStatusCode();
|
|
await SaveDownloadAsync(response, archive, cancellationToken);
|
|
await ExtractPackAsync(archive, extraction, cancellationToken);
|
|
current = update.Version;
|
|
File.WriteAllText(Path.Combine(source, "version.txt"), current.ToString());
|
|
}
|
|
if (attempt == 2) throw new IOException("Retro Rewind changed during installation. Retry to get the latest version.");
|
|
}
|
|
var destination = Path.Combine(staging, "RetroRewind6");
|
|
// Both directories are staged on the destination volume.
|
|
Directory.Move(source, destination);
|
|
File.Delete(archive);
|
|
return destination;
|
|
}
|
|
|
|
private static async Task SaveDownloadAsync(HttpResponseMessage response, string archive,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var length = response.Content.Headers.ContentLength;
|
|
if (length > MaximumDownloadBytes)
|
|
throw new InvalidDataException("Retro Rewind download exceeds the 4 GiB safety limit.");
|
|
InstallationDiskGuard.Check(archive, length ?? 0);
|
|
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
|
|
await using var output = File.Create(archive);
|
|
var buffer = new byte[81920];
|
|
long written = 0;
|
|
int count;
|
|
while ((count = await input.ReadAsync(buffer, cancellationToken)) != 0)
|
|
{
|
|
written = checked(written + count);
|
|
if (written > MaximumDownloadBytes)
|
|
throw new InvalidDataException("Retro Rewind download exceeds the 4 GiB safety limit.");
|
|
InstallationDiskGuard.Check(archive, count);
|
|
await output.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
|
|
}
|
|
}
|
|
|
|
internal static async Task ExtractPackAsync(string archive, string destination,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var zip = ZipFile.OpenRead(archive);
|
|
long expanded = 0;
|
|
foreach (var entry in zip.Entries)
|
|
{
|
|
expanded = checked(expanded + entry.Length);
|
|
if (expanded > MaximumExtractedBytes)
|
|
throw new InvalidDataException("Retro Rewind archive exceeds the 8 GiB extraction safety limit.");
|
|
}
|
|
InstallationDiskGuard.Check(destination, expanded);
|
|
var root = Path.GetFullPath(destination).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
|
foreach (var entry in zip.Entries)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var path = Path.GetFullPath(Path.Combine(root, entry.FullName.Replace('/', Path.DirectorySeparatorChar)));
|
|
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
|
throw new InvalidDataException("Unsafe path in the Retro Rewind archive.");
|
|
if (entry.FullName.EndsWith('/')) { Directory.CreateDirectory(path); continue; }
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
await using var input = entry.Open();
|
|
await using var output = File.Create(path);
|
|
var buffer = new byte[81920];
|
|
long written = 0;
|
|
int count;
|
|
while ((count = await input.ReadAsync(buffer, cancellationToken)) != 0)
|
|
{
|
|
written = checked(written + count);
|
|
if (written > entry.Length)
|
|
throw new InvalidDataException("Retro Rewind archive entry exceeds its advertised size.");
|
|
InstallationDiskGuard.Check(path, count);
|
|
await output.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
|
|
}
|
|
}
|
|
}
|
|
}
|