mirror of
https://github.com/patchzyy/wiicompiled
synced 2026-09-10 17:16:47 -04:00
refactor WiiCompiled.Setup into WiiCompiled.Setup.Windows and add WiiCompiled.Setup.Common
the idea behind this is C# code that is OS agnostic can go in WiiCompiled.Setup.Common to be shared by any OS specific code (eg: WiiCompiled.Setup.Windows and WiiCompiled.Setup.Linux).
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>
|
||||
/// One entry of an exact regular directory tree. Directory topology is part of the content
|
||||
/// contract everywhere this walker is used: an empty directory can be a runtime-visible asset just
|
||||
/// as a regular file can be, so it must not disappear from a content identity or a staged copy.
|
||||
/// </summary>
|
||||
public sealed record RegularTreeEntry(string RelativePath, string FullPath, bool IsDirectory,
|
||||
bool IsEmptyDirectory, long Length);
|
||||
|
||||
public static class FileSystemUtilities
|
||||
{
|
||||
public static void CopyDirectory(string source, string destination,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Directory.CreateDirectory(destination);
|
||||
foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory)));
|
||||
}
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var output = Path.Combine(destination, Path.GetRelativePath(source, file));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(output)!);
|
||||
File.Copy(file, output, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates an exact tree without following links, sorted by forward-slash relative path.
|
||||
/// Every directory appears exactly once and is flagged when it has no children at all.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<RegularTreeEntry> EnumerateRegularTree(string root,
|
||||
CancellationToken cancellationToken = default, string description = "The directory tree")
|
||||
{
|
||||
var rootInfo = new DirectoryInfo(Path.GetFullPath(root));
|
||||
if (!rootInfo.Exists)
|
||||
throw new DirectoryNotFoundException($"{description} is missing: {rootInfo.FullName}");
|
||||
RejectReparsePoint(rootInfo, description);
|
||||
|
||||
var entries = new List<RegularTreeEntry>();
|
||||
var pending = new Stack<DirectoryInfo>();
|
||||
pending.Push(rootInfo);
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var directory = pending.Pop();
|
||||
var hasChild = false;
|
||||
foreach (var entry in directory.EnumerateFileSystemInfos())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
RejectReparsePoint(entry, description);
|
||||
hasChild = true;
|
||||
switch (entry)
|
||||
{
|
||||
case DirectoryInfo child:
|
||||
pending.Push(child);
|
||||
break;
|
||||
case FileInfo file:
|
||||
entries.Add(new RegularTreeEntry(Relative(rootInfo, file.FullName), file.FullName,
|
||||
false, false, file.Length));
|
||||
break;
|
||||
default:
|
||||
throw new InvalidDataException(
|
||||
$"{description} contains an unsupported file-system entry: {entry.FullName}");
|
||||
}
|
||||
}
|
||||
|
||||
if (directory != rootInfo)
|
||||
entries.Add(new RegularTreeEntry(Relative(rootInfo, directory.FullName), directory.FullName,
|
||||
true, !hasChild, 0));
|
||||
}
|
||||
|
||||
entries.Sort((left, right) => StringComparer.Ordinal.Compare(left.RelativePath, right.RelativePath));
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>Removes exactly this directory, clearing attributes that would refuse deletion.</summary>
|
||||
public static void DeleteDirectoryIfExists(string path)
|
||||
{
|
||||
if (!Directory.Exists(path)) return;
|
||||
foreach (var entry in Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
try { File.SetAttributes(entry, FileAttributes.Normal); }
|
||||
catch { }
|
||||
}
|
||||
try { File.SetAttributes(path, FileAttributes.Normal); }
|
||||
catch { }
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One comparable spelling of a path. Normalization happens before the trailing separator is
|
||||
/// dropped, because <c>Path.GetFullPath("C:")</c> is the current directory on that drive rather
|
||||
/// than the drive root.
|
||||
/// </summary>
|
||||
public static string NormalizePath(string path) =>
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
|
||||
|
||||
/// <summary>Whether two paths name the same location, after normalization.</summary>
|
||||
public static bool PathsEqual(string left, string right) =>
|
||||
NormalizePath(left).Equals(NormalizePath(right), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Whether <paramref name="candidate"/> is <paramref name="container"/> or inside it, using a
|
||||
/// normalized prefix check so <c>C:\Games2</c> doesn't match inside <c>C:\Games</c>.</summary>
|
||||
public static bool PathContains(string container, string candidate)
|
||||
{
|
||||
container = NormalizePath(container);
|
||||
candidate = NormalizePath(candidate);
|
||||
return candidate.Equals(container, StringComparison.OrdinalIgnoreCase) ||
|
||||
candidate.StartsWith(container + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Whether two paths are equal or one contains the other, after normalization.</summary>
|
||||
public static bool PathsOverlap(string left, string right) =>
|
||||
PathContains(left, right) || PathContains(right, left);
|
||||
|
||||
/// <summary>Location rules shared by install directories and portable roots (both are trees setup may
|
||||
/// replace and uninstall may delete). <paramref name="subject"/> only names what's being rejected.</summary>
|
||||
public static void EnsureUsableLocation(string path, string subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new InvalidOperationException($"{subject} is required.");
|
||||
if (path.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"{subject} must be on a local drive, not a network path.");
|
||||
if (path.Length > MaximumLocationLength)
|
||||
throw new InvalidOperationException(
|
||||
$"{subject} path is too long. Choose a path shorter than {MaximumLocationLength} characters.");
|
||||
|
||||
var full = NormalizePath(path);
|
||||
var drive = Path.GetPathRoot(full);
|
||||
if (string.IsNullOrWhiteSpace(drive) ||
|
||||
full.Equals(NormalizePath(drive), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{subject} must be a subfolder, not the root of a drive. " +
|
||||
"Choose a dedicated folder such as D:\\Games\\WiiCompiled\\Install.");
|
||||
}
|
||||
|
||||
var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
|
||||
if (!string.IsNullOrEmpty(windows) && PathContains(windows, full))
|
||||
throw new InvalidOperationException($"{subject} cannot be inside the Windows directory.");
|
||||
|
||||
foreach (var folder in ReservedLocations)
|
||||
{
|
||||
var reserved = Environment.GetFolderPath(folder);
|
||||
if (string.IsNullOrEmpty(reserved)) continue;
|
||||
if (full.Equals(NormalizePath(reserved), StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"{subject} cannot be {reserved}. Choose a dedicated folder.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The local build nests deep paths (llvm-mingw headers, CMake object directories) below the
|
||||
/// installation, so the root itself has to stay well inside the classic path limit.
|
||||
/// </summary>
|
||||
private const int MaximumLocationLength = 180;
|
||||
|
||||
/// <summary>Well-known folders that must never <em>be</em> an installation or portable root.</summary>
|
||||
private static readonly Environment.SpecialFolder[] ReservedLocations =
|
||||
[
|
||||
Environment.SpecialFolder.ProgramFiles,
|
||||
Environment.SpecialFolder.ProgramFilesX86,
|
||||
Environment.SpecialFolder.CommonProgramFiles,
|
||||
Environment.SpecialFolder.System,
|
||||
Environment.SpecialFolder.SystemX86,
|
||||
Environment.SpecialFolder.UserProfile,
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolder.ApplicationData,
|
||||
Environment.SpecialFolder.CommonApplicationData,
|
||||
Environment.SpecialFolder.DesktopDirectory,
|
||||
Environment.SpecialFolder.MyDocuments
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Writes a file so that a reader only ever sees the old contents or the new ones: the bytes go
|
||||
/// to a sibling temporary and are renamed over the destination, which is atomic on NTFS.
|
||||
/// Interrupting the write leaves the temporary behind, never a truncated document.
|
||||
/// </summary>
|
||||
public static void WriteAtomic(string path, byte[] contents)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
|
||||
var temporary = path + $".tmp-{Guid.NewGuid():N}";
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(temporary, contents);
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(temporary); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public static void WriteAtomic(string path, string contents) =>
|
||||
WriteAtomic(path, System.Text.Encoding.UTF8.GetBytes(contents));
|
||||
|
||||
/// <summary>
|
||||
/// Refuses an operation that cannot fit on the destination drive. The caller owns the allowance
|
||||
/// it needs; this owns the one message and the one drive-readiness rule both paths report.
|
||||
/// </summary>
|
||||
public static void EnsureFreeSpace(string path, long required, string operationDescription)
|
||||
{
|
||||
var root = Path.GetPathRoot(path)
|
||||
?? throw new InvalidOperationException(
|
||||
"The installation directory is not on a local drive.");
|
||||
var drive = new DriveInfo(root);
|
||||
if (!drive.IsReady)
|
||||
throw new IOException($"The destination drive {root} is not ready.");
|
||||
if (drive.AvailableFreeSpace < required)
|
||||
{
|
||||
throw new IOException(
|
||||
$"Not enough free space on {root}. {operationDescription} needs approximately " +
|
||||
$"{FormatGiB(required)} free, but only {FormatGiB(drive.AvailableFreeSpace)} is available.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatGiB(long bytes) => $"{bytes / (1024d * 1024 * 1024):0.0} GiB";
|
||||
|
||||
public static void RejectReparsePoint(FileSystemInfo entry, string description)
|
||||
{
|
||||
if ((entry.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
throw new InvalidDataException(
|
||||
$"{description} contains a reparse point instead of a regular entry: {entry.FullName}");
|
||||
}
|
||||
|
||||
private static string Relative(DirectoryInfo root, string fullPath) =>
|
||||
Path.GetRelativePath(root.FullName, fullPath).Replace('\\', '/');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>Reads and atomically writes the small JSON state documents each installer keeps.</summary>
|
||||
public static class JsonState
|
||||
{
|
||||
private static readonly JsonSerializerOptions ReadOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
|
||||
public static T? TryRead<T>(string path) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(path) ? JsonSerializer.Deserialize<T>(File.ReadAllText(path), ReadOptions) : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A truncated or hand-edited state document must degrade into "unknown", which every
|
||||
// caller already treats as "assume stale and rebuild", not into a crash.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Write<T>(string path, T value)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
||||
var tempPath = path + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
File.WriteAllText(tempPath, JsonSerializer.Serialize(value, WriteOptions));
|
||||
File.Move(tempPath, path, overwrite: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>Disc metadata parsed from `nodtool info`'s stdout.</summary>
|
||||
public sealed record NodToolDiscInfo(string GameId, string Title, int Revision);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the plain-text stdout of `nodtool info <iso>`. nodtool has no JSON output mode, but
|
||||
/// prints one unconditional disc-level Title/Game ID/Disc-Revision block (via its own
|
||||
/// `print_header`) before any per-partition breakdown - Wii discs also have differently-scoped
|
||||
/// "Title"/"Game ID" lines per update/channel partition further down, so the first match of each
|
||||
/// pattern is always the disc-level one both installers want.
|
||||
/// </summary>
|
||||
public static partial class NodToolInfoParser
|
||||
{
|
||||
public static NodToolDiscInfo Parse(string infoStdout)
|
||||
{
|
||||
var gameIdMatch = GameIdLine().Match(infoStdout);
|
||||
if (!gameIdMatch.Success)
|
||||
throw new InvalidOperationException("nodtool did not return disc metadata.");
|
||||
var titleMatch = TitleLine().Match(infoStdout);
|
||||
var revisionMatch = RevisionLine().Match(infoStdout);
|
||||
return new NodToolDiscInfo(
|
||||
GameId: gameIdMatch.Groups[1].Value,
|
||||
Title: titleMatch.Success ? titleMatch.Groups[1].Value : "",
|
||||
Revision: revisionMatch.Success ? int.Parse(revisionMatch.Groups[1].Value) : 0);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^Game ID: (\S+)", RegexOptions.Multiline)]
|
||||
private static partial Regex GameIdLine();
|
||||
|
||||
[GeneratedRegex(@"^Title: (.+)$", RegexOptions.Multiline)]
|
||||
private static partial Regex TitleLine();
|
||||
|
||||
[GeneratedRegex(@"^Disc \d+, Revision (\d+)", RegexOptions.Multiline)]
|
||||
private static partial Regex RevisionLine();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the `nodtool` binary both installers use for Wii disc validation/extraction (see
|
||||
/// NodToolInfoParser.cs), replacing the earlier dependency on `dolphin-tool`/`DolphinTool.exe`. A
|
||||
/// caller can supply one directly; otherwise this downloads the matching prebuilt release binary
|
||||
/// from encounter/nod and caches it at Launcher/artifacts/nodtool[.exe].
|
||||
///
|
||||
/// Shared by: WiiCompiled.Setup.Linux/DiscTool.cs (falls back to this at end-user install time on
|
||||
/// a plain git checkout), and WiiCompiled.Setup.Common.Cli (invoked once at packaging time by both
|
||||
/// build-appimage.sh and Build-Installer.ps1 to acquire the copy each bundles).
|
||||
/// </summary>
|
||||
public static class NodToolProvider
|
||||
{
|
||||
public const string Version = "v2.0.0-alpha.10";
|
||||
|
||||
public static async Task<string> ResolveAsync(string workspace, CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheName = OperatingSystem.IsWindows() ? "nodtool.exe" : "nodtool";
|
||||
var cachePath = Path.Combine(workspace, "Launcher", "artifacts", cacheName);
|
||||
if (File.Exists(cachePath)) return cachePath;
|
||||
|
||||
var url = $"https://github.com/encounter/nod/releases/download/{Version}/{AssetName()}";
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
|
||||
var tempPath = cachePath + ".tmp";
|
||||
using (var http = new HttpClient())
|
||||
using (var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var fileStream = File.Create(tempPath);
|
||||
await response.Content.CopyToAsync(fileStream, cancellationToken);
|
||||
}
|
||||
File.Move(tempPath, cachePath, overwrite: true);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(cachePath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
}
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
private static string AssetName()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "nodtool-windows-x86_64.exe",
|
||||
Architecture.Arm64 => "nodtool-windows-arm64.exe",
|
||||
Architecture.X86 => "nodtool-windows-x86.exe",
|
||||
var other => throw new PlatformNotSupportedException($"No prebuilt nodtool release for Windows {other}"),
|
||||
};
|
||||
}
|
||||
return RuntimeInformation.OSArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "nodtool-linux-x86_64",
|
||||
Architecture.Arm64 => "nodtool-linux-aarch64",
|
||||
Architecture.X86 => "nodtool-linux-i686",
|
||||
var other => throw new PlatformNotSupportedException($"No prebuilt nodtool release for Linux {other}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>
|
||||
/// A portable installation is a self-contained directory tree the user can move or carry on removable media:
|
||||
/// <code>
|
||||
/// <root>\portable.txt marker; its contents are irrelevant
|
||||
/// <root>\Install\ the installation directory
|
||||
/// <root>\UserData\ runtime user state (Config.toml, NAND, Cache, Logs)
|
||||
/// </code>
|
||||
/// The runtime finds the same root independently (<c>runtime/include/runtime_config.h</c>,
|
||||
/// <c>PortableRootDirectory</c>); this class must keep the same marker name, layout, and depth bound.
|
||||
/// Shared by both installers - Linux's CLI has no <c>--portable</c> flag, so it only ever calls
|
||||
/// <see cref="TryFind"/>/<see cref="UserDataDirectory"/>/<see cref="Contains"/> (always missing,
|
||||
/// since it never creates a marker file), not <see cref="Create"/>.
|
||||
/// </summary>
|
||||
public static class PortableRoot
|
||||
{
|
||||
public const string MarkerFileName = "portable.txt";
|
||||
public const string UserDataDirectoryName = "UserData";
|
||||
public const string InstallDirectoryName = "Install";
|
||||
|
||||
/// <summary>
|
||||
/// Parents searched above the start directory. Kept small (installs sit 2 levels below root,
|
||||
/// <c><root>\Install\Base\game.exe</c>) so an unrelated marker far up a drive can't capture it.
|
||||
/// </summary>
|
||||
public const int MaximumSearchDepth = 4;
|
||||
|
||||
/// <summary>
|
||||
/// The portable root <paramref name="startDirectory"/> belongs to, or null otherwise. Callers pass the
|
||||
/// installation directory (not the running executable's location), since a downloaded setup runs from Downloads.
|
||||
/// </summary>
|
||||
public static string? TryFind(string startDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(startDirectory)) return null;
|
||||
string current;
|
||||
try
|
||||
{
|
||||
// Normalize before trimming: "C:\" trimmed to "C:" is the current directory on that
|
||||
// drive, not the drive root.
|
||||
current = Normalize(startDirectory);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var level = 0; level <= MaximumSearchDepth; level++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current, MarkerFileName))) return current;
|
||||
var parent = Path.GetDirectoryName(current);
|
||||
if (string.IsNullOrEmpty(parent) || parent.Equals(current, StringComparison.Ordinal)) break;
|
||||
current = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string UserDataDirectory(string root) => Path.Combine(root, UserDataDirectoryName);
|
||||
|
||||
/// <summary>Whether <paramref name="candidate"/> is <paramref name="root"/> or lives inside it.</summary>
|
||||
public static bool Contains(string root, string candidate) =>
|
||||
FileSystemUtilities.PathContains(root, candidate);
|
||||
|
||||
/// <summary>
|
||||
/// Establishes the root an install is about to publish into: the marker and the user-state
|
||||
/// directory must exist before anything resolves a configuration path, because that resolution is
|
||||
/// what decides whether the installation writes portable or machine-wide settings.
|
||||
/// </summary>
|
||||
public static string Create(string root)
|
||||
{
|
||||
var full = Normalize(root);
|
||||
EnsureUsableRoot(full);
|
||||
Directory.CreateDirectory(full);
|
||||
Directory.CreateDirectory(UserDataDirectory(full));
|
||||
var marker = Path.Combine(full, MarkerFileName);
|
||||
if (!File.Exists(marker))
|
||||
{
|
||||
File.WriteAllText(marker,
|
||||
"WiiCompiled portable installation." + Environment.NewLine +
|
||||
"This marker makes the runtime keep Config.toml, NAND, Cache, and Logs in UserData\\ " +
|
||||
"beside it instead of in %LOCALAPPDATA%." + Environment.NewLine +
|
||||
"Delete it to make this installation use per-user application data again." +
|
||||
Environment.NewLine);
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A portable root is deleted wholesale by the user, so like an install directory it may never be a
|
||||
/// drive root or well-known system location; both share <see cref="FileSystemUtilities.EnsureUsableLocation"/>.
|
||||
/// </summary>
|
||||
public static void EnsureUsableRoot(string root) =>
|
||||
FileSystemUtilities.EnsureUsableLocation(root, "A portable installation root");
|
||||
|
||||
private static string Normalize(string path) => FileSystemUtilities.NormalizePath(path);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the one canonical Retro Rewind install. Wheel Wizard owns and passes it as
|
||||
/// <c>--retro-dir</c>; each installer only resolves, reads, and records it, never packages or
|
||||
/// copies it.
|
||||
/// </summary>
|
||||
public static class RetroRewindSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the <c>RetroRewind6</c> folder from a selection that may be the folder itself or a
|
||||
/// parent containing exactly one <c>RetroRewind6/Binaries/Code.pul</c>.
|
||||
/// </summary>
|
||||
public static string ResolveRetroRewind6(string selected)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selected))
|
||||
throw new InvalidDataException("Choose the canonical Retro Rewind folder.");
|
||||
var root = Path.GetFullPath(selected);
|
||||
if (!Directory.Exists(root))
|
||||
throw new DirectoryNotFoundException($"The Retro Rewind folder does not exist: {root}");
|
||||
|
||||
var candidates = new List<string> { root, Path.Combine(root, "RetroRewind6") };
|
||||
// A folder produced by unpacking the published distribution keeps an extra wrapper directory.
|
||||
foreach (var child in Directory.EnumerateDirectories(root))
|
||||
candidates.Add(Path.Combine(child, "RetroRewind6"));
|
||||
|
||||
var matches = candidates
|
||||
.Where(candidate => File.Exists(Path.Combine(candidate, "Binaries", "Code.pul")))
|
||||
.Select(Path.GetFullPath)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return matches.Count switch
|
||||
{
|
||||
1 => ResolveFinalDirectory(matches[0]),
|
||||
0 => throw new InvalidDataException(
|
||||
$"{root} does not contain RetroRewind6\\Binaries\\Code.pul. " +
|
||||
"Select the canonical Retro Rewind folder, or the folder that contains it."),
|
||||
_ => throw new InvalidDataException(
|
||||
$"{root} contains more than one RetroRewind6\\Binaries\\Code.pul. " +
|
||||
"Select the exact Retro Rewind folder to use.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Must resolve a junction/symlink RetroRewind6 to its real directory before fingerprinting: the
|
||||
/// compile-input identity records on-disk kind, and a snapshot copy is always a real directory, so an
|
||||
/// unresolved link can never match its own copy and installs fail forever with a misleading error.
|
||||
/// </summary>
|
||||
private static string ResolveFinalDirectory(string path)
|
||||
{
|
||||
var directory = new DirectoryInfo(path);
|
||||
if (directory.LinkTarget is null)
|
||||
return path;
|
||||
var target = directory.ResolveLinkTarget(returnFinalTarget: true)?.FullName;
|
||||
if (target is null || !Directory.Exists(target))
|
||||
throw new InvalidDataException($"The Retro Rewind folder is a link to a missing target: {path}");
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>
|
||||
/// One validated, content-identified download in operation-owned scratch space. Callers use this
|
||||
/// exact directory for both the update decision and any resulting build.
|
||||
/// </summary>
|
||||
public sealed record RetroWfcPayloadSnapshot(string Directory, string Sha256, long ByteLength);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads and verifies the Retro-WFC payload (a small, RSA-signed blob served from a single
|
||||
/// fixed endpoint). Shared by both installers - moved here from WiiCompiled.Setup.Windows's
|
||||
/// InputValidation.cs, which keeps every one of these method names as thin forwarding wrappers so
|
||||
/// its many existing call sites (ProductRepairService.cs, LocalBuildService.cs, Installation.cs,
|
||||
/// SelfTests.cs) needed no changes.
|
||||
/// </summary>
|
||||
public static class RetroWfcPayload
|
||||
{
|
||||
private const long MaximumRetroWfcPayloadBytes = 16L * 1024 * 1024;
|
||||
// The payload is tens of kilobytes from a single fixed endpoint 30s is good.
|
||||
private static readonly TimeSpan RetroWfcDownloadTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan RetroWfcRetryDelay = TimeSpan.FromSeconds(1);
|
||||
|
||||
public const string CurrentRetroWfcPayloadUri = "http://nas.play.rwfc.net/payload?g=RMCPD00";
|
||||
private static readonly string RetroWfcOfflinePayloadFile =
|
||||
Path.Combine("binary", "payload.RMCPD00.bin");
|
||||
|
||||
/// <summary>Retro-WFC production payload signing key (PROD PayloadPublicKey). Verifies
|
||||
/// the payload past the 0x110 header; on-console stage1 pins the same key, so
|
||||
/// rotating it upstream is a breaking release there too.</summary>
|
||||
private static readonly byte[] RetroWfcPayloadSigningModulus = Convert.FromHexString(
|
||||
"e6e6ce416f350422cbe26c36a67eba613dddcd27d79afd077dcc593e5319eaa6" +
|
||||
"080293400033876d3dbdfda12c15f46ac8e4f5b40c56e7b5f67e91647d618cb9" +
|
||||
"99c041581b86d103bd7723fceac03ad3ad5134bf611cd47dc527002596821e94" +
|
||||
"1c9470938fea07238a84767323e4a610bd996465e59d04dae4febd915c96fc07" +
|
||||
"39e4e818300829d78f3f2275e1f3fbd2507f1bde74f24a5285e61007b959a583" +
|
||||
"b4820d75eca76680866efe5d79590b82c3577b796155899530e305b94b4ceef4" +
|
||||
"428644b719df3d8540c9588f5bb02d83d3938255d1a1e073d3408163ff93a615" +
|
||||
"a2106a03923a397aad6a29ebb43031ed06de1575c8ee2b54678fa059e025f455");
|
||||
|
||||
private const int RetroWfcPayloadSignedRegionOffset = 0x110;
|
||||
private const int RetroWfcPayloadSignatureOffset = 0x10;
|
||||
private const int RetroWfcPayloadMinimumBytes = 0x130;
|
||||
|
||||
public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory,
|
||||
RSAParameters? signingKey = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(stagedDirectory))
|
||||
throw new InvalidDataException("The staged Retro-WFC payload directory is missing.");
|
||||
|
||||
var root = Path.GetFullPath(stagedDirectory);
|
||||
var payload = Path.Combine(root, RetroWfcOfflinePayloadFile);
|
||||
if (!File.Exists(payload))
|
||||
throw new InvalidDataException(
|
||||
"The staged Retro-WFC payload directory does not contain binary\\payload.RMCPD00.bin.");
|
||||
ValidateRetroWfcPayloadFile(payload, signingKey);
|
||||
return root;
|
||||
}
|
||||
|
||||
public static string ResolveRetroWfcPayloadFile(string stagedDirectory,
|
||||
RSAParameters? signingKey = null) =>
|
||||
Path.Combine(ValidateStagedRetroWfcPayloadDirectory(stagedDirectory, signingKey),
|
||||
RetroWfcOfflinePayloadFile);
|
||||
|
||||
public static string ComputeRetroWfcPayloadSha256(string stagedDirectory,
|
||||
RSAParameters? signingKey = null) =>
|
||||
Sha256File(ResolveRetroWfcPayloadFile(stagedDirectory, signingKey));
|
||||
|
||||
public static void ValidateRetroWfcPayloadUri(string uriText)
|
||||
{
|
||||
if (!string.Equals(uriText, CurrentRetroWfcPayloadUri, StringComparison.Ordinal))
|
||||
throw new InvalidDataException("The installer does not define the fixed Retro-WFC payload endpoint.");
|
||||
}
|
||||
|
||||
public static async Task<RetroWfcPayloadSnapshot> DownloadRetroWfcPayloadAsync(string uriText,
|
||||
string destinationDirectory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateRetroWfcPayloadUri(uriText);
|
||||
var uri = new Uri(uriText, UriKind.Absolute);
|
||||
|
||||
var root = Path.GetFullPath(destinationDirectory);
|
||||
var destination = Path.Combine(root, RetroWfcOfflinePayloadFile);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
using var handler = new HttpClientHandler { AllowAutoRedirect = false };
|
||||
using var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
|
||||
|
||||
for (var attempt = 1; attempt <= 2; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await DownloadRetroWfcPayloadAttemptAsync(client, uri, root, destination,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (attempt == 1 &&
|
||||
IsTransientRetroWfcDownloadFailure(ex, cancellationToken))
|
||||
{
|
||||
await Task.Delay(RetroWfcRetryDelay, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("The Retro-WFC download retry loop ended unexpectedly.");
|
||||
}
|
||||
|
||||
private static async Task<RetroWfcPayloadSnapshot> DownloadRetroWfcPayloadAttemptAsync(
|
||||
HttpClient client, Uri uri, string root, string destination, CancellationToken cancellationToken)
|
||||
{
|
||||
var temporary = destination + $".tmp-{Guid.NewGuid():N}";
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(RetroWfcDownloadTimeout);
|
||||
var attemptToken = timeout.Token;
|
||||
try
|
||||
{
|
||||
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, attemptToken);
|
||||
if ((int)response.StatusCode is >= 300 and < 400)
|
||||
throw new InvalidDataException(
|
||||
"The fixed Retro-WFC payload endpoint redirected; refusing to fetch from a different target.");
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (response.Content.Headers.ContentLength is > MaximumRetroWfcPayloadBytes)
|
||||
throw new InvalidDataException("The Retro-WFC payload is unexpectedly large.");
|
||||
await using var input = await response.Content.ReadAsStreamAsync(attemptToken);
|
||||
long total = 0;
|
||||
string actualSha256;
|
||||
await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write,
|
||||
FileShare.None, 64 * 1024, FileOptions.Asynchronous))
|
||||
{
|
||||
var buffer = new byte[64 * 1024];
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
while (true)
|
||||
{
|
||||
var read = await input.ReadAsync(buffer, attemptToken);
|
||||
if (read == 0) break;
|
||||
total = checked(total + read);
|
||||
if (total > MaximumRetroWfcPayloadBytes)
|
||||
throw new InvalidDataException("The Retro-WFC payload is unexpectedly large.");
|
||||
hash.AppendData(buffer, 0, read);
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), attemptToken);
|
||||
}
|
||||
actualSha256 = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
|
||||
}
|
||||
ValidateRetroWfcPayloadFile(temporary);
|
||||
File.Move(temporary, destination, overwrite: true);
|
||||
return new RetroWfcPayloadSnapshot(root, actualSha256, total);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"The Retro-WFC payload download did not finish within {RetroWfcDownloadTimeout.TotalMinutes:0} minutes.",
|
||||
ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(temporary); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsTransientRetroWfcDownloadFailure(Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return false;
|
||||
if (exception is TimeoutException) return true;
|
||||
if (exception is not HttpRequestException request) return false;
|
||||
return request.StatusCode is null or HttpStatusCode.RequestTimeout or
|
||||
HttpStatusCode.TooManyRequests ||
|
||||
request.StatusCode is { } status && (int)status >= 500;
|
||||
}
|
||||
|
||||
private static void ValidateRetroWfcPayloadFile(string payload, RSAParameters? signingKey = null)
|
||||
{
|
||||
byte[] image;
|
||||
using (var stream = File.OpenRead(payload))
|
||||
{
|
||||
if (stream.Length > MaximumRetroWfcPayloadBytes)
|
||||
throw new InvalidDataException("The Retro-WFC payload is unexpectedly large.");
|
||||
if (stream.Length < RetroWfcPayloadMinimumBytes)
|
||||
throw new InvalidDataException("The Retro-WFC payload has an invalid header.");
|
||||
image = new byte[stream.Length];
|
||||
stream.ReadExactly(image);
|
||||
}
|
||||
|
||||
if (!image.AsSpan(0, 12).SequenceEqual("WWFC/Payload"u8))
|
||||
throw new InvalidDataException("The Retro-WFC payload has an invalid header.");
|
||||
var declaredSize = BinaryPrimitives.ReadUInt32BigEndian(image.AsSpan(0x0C));
|
||||
if (declaredSize != image.Length)
|
||||
throw new InvalidDataException(
|
||||
$"The Retro-WFC payload declares {declaredSize} bytes but contains {image.Length}.");
|
||||
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportParameters(signingKey ?? new RSAParameters
|
||||
{
|
||||
Modulus = RetroWfcPayloadSigningModulus,
|
||||
Exponent = [0x01, 0x00, 0x01],
|
||||
});
|
||||
if (!rsa.VerifyData(
|
||||
image.AsSpan(RetroWfcPayloadSignedRegionOffset),
|
||||
image.AsSpan(RetroWfcPayloadSignatureOffset,
|
||||
RetroWfcPayloadSignedRegionOffset - RetroWfcPayloadSignatureOffset),
|
||||
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
|
||||
throw new InvalidDataException(
|
||||
"The Retro-WFC payload is not signed by the pinned Retro-WFC signing key.");
|
||||
}
|
||||
|
||||
private static string Sha256File(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
public sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents);
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes the runtime's <c>Config.toml</c>. Every entry point takes the file it operates on
|
||||
/// since its location depends on the installation (portable <c>UserData</c> vs. per-user app data);
|
||||
/// callers obtain it once via <see cref="ResolveConfigPath"/>. Shared by both installers - Linux
|
||||
/// never creates a <see cref="PortableRoot.MarkerFileName"/> marker file, so
|
||||
/// <see cref="ResolveConfigPath"/>/<see cref="FormatPathValue"/>'s portable-root lookups always miss
|
||||
/// there and this degrades to the same plain per-user-app-data, always-absolute-path behavior a
|
||||
/// non-portable Windows install already gets.
|
||||
/// </summary>
|
||||
public static class RuntimeConfiguration
|
||||
{
|
||||
public const string ConfigFileName = "Config.toml";
|
||||
|
||||
/// <summary>
|
||||
/// Ordinal, matching the runtime parser/writer (<c>runtime_config.h</c>) exactly. Case-insensitive
|
||||
/// matching here would let setup rewrite a <c>[Paths]</c>/<c>Dvd_Root</c> spelling the runtime ignores.
|
||||
/// </summary>
|
||||
private const StringComparison KeyComparison = StringComparison.Ordinal;
|
||||
|
||||
/// <summary>The per-user configuration an ordinary (non-portable) installation shares.</summary>
|
||||
public static string ApplicationDataConfigPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"WiiCompiled", ConfigFileName);
|
||||
|
||||
/// <summary>
|
||||
/// The configuration file that governs <paramref name="installDirectory"/>. This mirrors
|
||||
/// <c>RuntimeConfigFile::ResolveConfigPath</c> in the runtime: portable roots win, everything
|
||||
/// else is per-user application data.
|
||||
/// </summary>
|
||||
public static string ResolveConfigPath(string? installDirectory)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(installDirectory) &&
|
||||
PortableRoot.TryFind(installDirectory) is { } portableRoot)
|
||||
return Path.Combine(PortableRoot.UserDataDirectory(portableRoot), ConfigFileName);
|
||||
return ApplicationDataConfigPath;
|
||||
}
|
||||
|
||||
public static RuntimeConfigSnapshot Capture(string configPath) =>
|
||||
File.Exists(configPath)
|
||||
? new RuntimeConfigSnapshot(true, File.ReadAllBytes(configPath))
|
||||
: new RuntimeConfigSnapshot(false, []);
|
||||
|
||||
public static void Restore(string configPath, RuntimeConfigSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.Existed)
|
||||
FileSystemUtilities.WriteAtomic(configPath, snapshot.Contents);
|
||||
else
|
||||
File.Delete(configPath);
|
||||
}
|
||||
|
||||
public static void SetDvdRoot(string configPath, string dvdRoot) =>
|
||||
SetPath(configPath, "dvd_root", dvdRoot);
|
||||
|
||||
/// <summary>
|
||||
/// Records the one canonical Retro Rewind installation Wheel Wizard owns. The runtime builds its
|
||||
/// asset overlay by scanning this directory live at launch, so an asset-only Retro Rewind update
|
||||
/// needs no backend work: the next launch simply reads the new files.
|
||||
/// </summary>
|
||||
public static void SetRetroRewindRoot(string configPath, string retroRewindRoot) =>
|
||||
SetPath(configPath, "retro_rewind_root", retroRewindRoot);
|
||||
|
||||
/// <summary>The canonical Retro Rewind root, or null when no installation has recorded one.</summary>
|
||||
public static string? GetRetroRewindRoot(string configPath) =>
|
||||
GetResolvedPath(configPath, "retro_rewind_root");
|
||||
|
||||
public static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) =>
|
||||
RemovePathIfOwned(configPath, "retro_rewind_root", retroRewindRoot);
|
||||
|
||||
public static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) =>
|
||||
RemovePathIfOwned(configPath, "dvd_root", dvdRoot);
|
||||
|
||||
/// <summary>The raw stored text of a <c>[paths]</c> key, exactly as the file holds it.</summary>
|
||||
public static string? GetPath(string configPath, string key) =>
|
||||
TryUnquoteToml(GetRawValue(configPath, "paths", key) ?? "", out var value) ? value : null;
|
||||
|
||||
/// <summary>
|
||||
/// A <c>[paths]</c> value as the runtime will actually use it. The runtime resolves every relative
|
||||
/// <c>[paths]</c> value against the directory holding <c>Config.toml</c> (never the working
|
||||
/// directory), so the host must resolve it the same way before comparing or reading it.
|
||||
/// </summary>
|
||||
public static string? GetResolvedPath(string configPath, string key)
|
||||
{
|
||||
var stored = GetPath(configPath, key);
|
||||
return string.IsNullOrWhiteSpace(stored) ? null : ResolveAgainstConfig(configPath, stored);
|
||||
}
|
||||
|
||||
public static string ConfigDirectory(string configPath) =>
|
||||
Path.GetDirectoryName(Path.GetFullPath(configPath))
|
||||
?? throw new InvalidOperationException($"{configPath} has no containing directory.");
|
||||
|
||||
public static string ResolveAgainstConfig(string configPath, string value) =>
|
||||
Path.GetFullPath(value, ConfigDirectory(configPath));
|
||||
|
||||
private static void SetPath(string configPath, string key, string value)
|
||||
{
|
||||
var lines = ReadLinesOrDefault(configPath);
|
||||
SetSectionValue(lines, "paths", key, QuoteToml(FormatPathValue(configPath, value)));
|
||||
WriteLines(configPath, lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paths inside the portable root are stored relative to the config directory (forward slashes,
|
||||
/// needing no TOML escaping) so the tree keeps working if the user moves or renames the root.
|
||||
/// Everything else, including all non-portable paths, is stored absolute.
|
||||
/// </summary>
|
||||
private static string FormatPathValue(string configPath, string value)
|
||||
{
|
||||
var full = Path.GetFullPath(value);
|
||||
var configDirectory = ConfigDirectory(configPath);
|
||||
if (PortableRoot.TryFind(configDirectory) is not { } root || !PortableRoot.Contains(root, full))
|
||||
return full;
|
||||
var relative = Path.GetRelativePath(configDirectory, full);
|
||||
return Path.IsPathRooted(relative)
|
||||
? full
|
||||
: relative.Replace(Path.DirectorySeparatorChar, '/');
|
||||
}
|
||||
|
||||
private static void RemovePathIfOwned(string configPath, string key, string expectedValue)
|
||||
{
|
||||
if (!File.Exists(configPath)) return;
|
||||
var lines = File.ReadAllLines(configPath).ToList();
|
||||
var inPaths = false;
|
||||
var changed = false;
|
||||
for (var index = 0; index < lines.Count; index++)
|
||||
{
|
||||
var trimmed = RemoveComment(lines[index]).Trim();
|
||||
if (IsSection(trimmed))
|
||||
{
|
||||
inPaths = trimmed.Equals("[paths]", KeyComparison);
|
||||
continue;
|
||||
}
|
||||
if (!inPaths || trimmed.Length == 0) continue;
|
||||
var equals = trimmed.IndexOf('=');
|
||||
if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison))
|
||||
continue;
|
||||
if (!TryUnquoteToml(trimmed[(equals + 1)..].Trim(), out var configured)) return;
|
||||
// A portable installation stores this relative, so ownership is decided on the path the
|
||||
// runtime would actually resolve, not on the stored text.
|
||||
if (!ResolveAgainstConfig(configPath, configured)
|
||||
.Equals(Path.GetFullPath(expectedValue), StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
lines.RemoveAt(index);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (changed) WriteLines(configPath, lines);
|
||||
}
|
||||
|
||||
/// <summary>The raw TOML literal stored for a key, or null when the section or key is absent.</summary>
|
||||
public static string? GetRawValue(string configPath, string section, string key)
|
||||
{
|
||||
if (!File.Exists(configPath)) return null;
|
||||
var header = $"[{section}]";
|
||||
var inSection = false;
|
||||
foreach (var line in File.ReadLines(configPath))
|
||||
{
|
||||
var trimmed = RemoveComment(line).Trim();
|
||||
if (IsSection(trimmed))
|
||||
{
|
||||
inSection = trimmed.Equals(header, KeyComparison);
|
||||
continue;
|
||||
}
|
||||
if (!inSection || trimmed.Length == 0) continue;
|
||||
var equals = trimmed.IndexOf('=');
|
||||
if (equals < 0 || !trimmed[..equals].Trim().Equals(key, KeyComparison)) continue;
|
||||
return trimmed[(equals + 1)..].Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces one key in one section, preserving comments, ordering, and every unrelated setting -
|
||||
/// this file belongs to the user and to the in-game settings bar as much as to setup. A
|
||||
/// commented-out key is deliberately not matched, matching the runtime's own writer.
|
||||
/// </summary>
|
||||
private static void SetSectionValue(List<string> lines, string section, string key, string value)
|
||||
{
|
||||
var header = $"[{section}]";
|
||||
var sectionStart = -1;
|
||||
var sectionEnd = lines.Count;
|
||||
for (var index = 0; index < lines.Count; index++)
|
||||
{
|
||||
var trimmed = RemoveComment(lines[index]).Trim();
|
||||
if (!IsSection(trimmed)) continue;
|
||||
if (sectionStart >= 0)
|
||||
{
|
||||
sectionEnd = index;
|
||||
break;
|
||||
}
|
||||
if (trimmed.Equals(header, KeyComparison)) sectionStart = index;
|
||||
}
|
||||
if (sectionStart < 0)
|
||||
{
|
||||
if (lines.Count > 0 && lines[^1].Length != 0) lines.Add("");
|
||||
lines.Add(header);
|
||||
lines.Add($"{key} = {value}");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var index = sectionStart + 1; index < sectionEnd; index++)
|
||||
{
|
||||
var trimmed = RemoveComment(lines[index]).Trim();
|
||||
if (trimmed.Length == 0) continue;
|
||||
var equals = trimmed.IndexOf('=');
|
||||
if (equals >= 0 && trimmed[..equals].Trim().Equals(key, KeyComparison))
|
||||
{
|
||||
lines[index] = $"{key} = {value}";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Append after the section's last real line, not after the blank line that separates it from
|
||||
// the next header, so a generated file stays readable for the user who also edits it by hand.
|
||||
var insertAt = sectionEnd;
|
||||
while (insertAt > sectionStart + 1 && lines[insertAt - 1].Trim().Length == 0) insertAt--;
|
||||
lines.Insert(insertAt, $"{key} = {value}");
|
||||
}
|
||||
|
||||
private static List<string> ReadLinesOrDefault(string configPath) =>
|
||||
File.Exists(configPath) ? File.ReadAllLines(configPath).ToList() : DefaultConfigLines();
|
||||
|
||||
private static void WriteLines(string configPath, List<string> lines) =>
|
||||
FileSystemUtilities.WriteAtomic(configPath,
|
||||
Encoding.UTF8.GetBytes(string.Join(Environment.NewLine, lines) + Environment.NewLine));
|
||||
|
||||
private static List<string> DefaultConfigLines() =>
|
||||
[
|
||||
"# WiiCompiled user configuration",
|
||||
"",
|
||||
"[paths]"
|
||||
];
|
||||
|
||||
private static bool IsSection(string value) =>
|
||||
value.Length >= 2 && value[0] == '[' && value[^1] == ']';
|
||||
|
||||
/// <summary>
|
||||
/// Line-for-line port of <c>RemoveComment</c> in <c>runtime_config.h</c>, including its backslash
|
||||
/// escaping inside basic strings: without it a Windows path ending in <c>\\"</c> reads as
|
||||
/// re-opening the quote, and the two implementations disagree about which <c>#</c> starts a comment.
|
||||
/// </summary>
|
||||
private static string RemoveComment(string line)
|
||||
{
|
||||
var inSingle = false;
|
||||
var inDouble = false;
|
||||
var escaped = false;
|
||||
for (var index = 0; index < line.Length; index++)
|
||||
{
|
||||
var character = line[index];
|
||||
if (inDouble && character == '\\' && !escaped)
|
||||
{
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character == '\'' && !inDouble) inSingle = !inSingle;
|
||||
else if (character == '"' && !inSingle && !escaped) inDouble = !inDouble;
|
||||
else if (character == '#' && !inSingle && !inDouble) return line[..index];
|
||||
escaped = false;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
private static string QuoteToml(string value) =>
|
||||
"\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
||||
|
||||
public static bool TryUnquoteToml(string value, out string result)
|
||||
{
|
||||
result = "";
|
||||
if (value.Length < 2) return false;
|
||||
// Literal strings carry no escapes; the runtime's parser treats them the same way.
|
||||
if (value[0] == '\'' && value[^1] == '\'')
|
||||
{
|
||||
result = value[1..^1];
|
||||
return true;
|
||||
}
|
||||
if (value[0] != '"' || value[^1] != '"') return false;
|
||||
var builder = new StringBuilder(value.Length - 2);
|
||||
for (var index = 1; index < value.Length - 1; index++)
|
||||
{
|
||||
if (value[index] == '\\' && index + 1 < value.Length - 1)
|
||||
{
|
||||
var escaped = value[++index];
|
||||
builder.Append(escaped switch { '\\' => '\\', '"' => '"', _ => escaped });
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(value[index]);
|
||||
}
|
||||
}
|
||||
result = builder.ToString();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
|
||||
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
|
||||
<Version>0.2.22</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
<Product>WiiCompiled</Product>
|
||||
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
|
||||
<DebugType>embedded</DebugType>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user