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:
@@ -21,7 +21,7 @@ $dependencySources = [IO.Path]::GetFullPath((Join-Path $repoRoot $DependencySour
|
||||
$workRoot = Join-Path $PSScriptRoot 'artifacts\installer-build'
|
||||
$publish = Join-Path $workRoot 'publish'
|
||||
$payloadRoot = Join-Path $workRoot 'payload'
|
||||
$setupProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup\WiiCompiled.Setup.csproj'
|
||||
$setupProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup.Windows\WiiCompiled.Setup.Windows.csproj'
|
||||
$translatorProject = Join-Path $repoRoot 'translator\src\Translator.Cli\Translator.Cli.csproj'
|
||||
$projectFile = Join-Path $repoRoot 'projects\mkwii\recomp.yml'
|
||||
|
||||
@@ -163,11 +163,11 @@ $translator = Join-Path $publish 'translator\Translator.Cli.exe'
|
||||
Assert-File $setupHost 'Published setup host'
|
||||
Assert-File $translator 'Self-contained translator'
|
||||
|
||||
# Resolved via the shared WiiCompiled.NodTool.Cli helper (also used by build-appimage.sh on Linux)
|
||||
# rather than a separate download/version-pin copy here: it downloads and caches the same way
|
||||
# Resolved via the shared WiiCompiled.Setup.Common.Cli helper (also used by build-appimage.sh on
|
||||
# Linux) rather than a separate download/version-pin copy here: it downloads and caches the same way
|
||||
# NodToolProvider.cs always does (Launcher/artifacts/nodtool.exe), replacing the old manual
|
||||
# -DolphinToolPath handoff with an automated, pinned acquisition step.
|
||||
$nodToolCliProject = Join-Path $PSScriptRoot 'WiiCompiled.NodTool.Cli'
|
||||
$nodToolCliProject = Join-Path $PSScriptRoot 'WiiCompiled.Setup.Common.Cli'
|
||||
$nodTool = (& dotnet run --project $nodToolCliProject -c Release -- --workspace $repoRoot | Select-Object -Last 1)
|
||||
if ($LASTEXITCODE -ne 0) { throw "nodtool resolution failed with exit code $LASTEXITCODE." }
|
||||
Assert-File $nodTool 'Resolved nodtool'
|
||||
|
||||
@@ -15,7 +15,7 @@ if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) {
|
||||
}
|
||||
$repoRoot = [IO.Path]::GetFullPath($RepositoryRoot)
|
||||
$launcher = Join-Path $repoRoot 'Launcher'
|
||||
$setup = Join-Path $launcher 'WiiCompiled.Setup'
|
||||
$setup = Join-Path $launcher 'WiiCompiled.Setup.Windows'
|
||||
|
||||
$failures = [Collections.Generic.List[string]]::new()
|
||||
function Add-Failure([string]$Message) { $failures.Add($Message) }
|
||||
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
using WiiCompiled.NodTool;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
// A packaging-time-only helper - never shipped, never run by an end user. Both
|
||||
// Launcher/build-appimage.sh and Launcher/Build-Installer.ps1 invoke this to obtain the nodtool
|
||||
// binary they bundle, so there is exactly one place (NodToolProvider) that knows the pinned
|
||||
// version/URL/platform-asset mapping, instead of a separate copy per packaging script.
|
||||
//
|
||||
// Usage: WiiCompiled.NodTool.Cli --workspace <repo-root>
|
||||
// Usage: WiiCompiled.Setup.Common.Cli --workspace <repo-root>
|
||||
// Prints the resolved nodtool path to stdout.
|
||||
|
||||
string? workspace = null;
|
||||
@@ -19,7 +19,7 @@ for (var i = 0; i < args.Length; i++)
|
||||
|
||||
if (workspace is null)
|
||||
{
|
||||
Console.Error.WriteLine("Usage: WiiCompiled.NodTool.Cli --workspace <repo-root>");
|
||||
Console.Error.WriteLine("Usage: WiiCompiled.Setup.Common.Cli --workspace <repo-root>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>WiiCompiled.NodTool.Cli</RootNamespace>
|
||||
<AssemblyName>WiiCompiled.NodTool.Cli</AssemblyName>
|
||||
<RootNamespace>WiiCompiled.Setup.Common.Cli</RootNamespace>
|
||||
<AssemblyName>WiiCompiled.Setup.Common.Cli</AssemblyName>
|
||||
<Version>0.2.22</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
<Product>WiiCompiled</Product>
|
||||
@@ -14,6 +14,6 @@
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WiiCompiled.NodTool\WiiCompiled.NodTool.csproj" />
|
||||
<ProjectReference Include="..\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
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>
|
||||
internal sealed record RegularTreeEntry(string RelativePath, string FullPath, bool IsDirectory,
|
||||
public sealed record RegularTreeEntry(string RelativePath, string FullPath, bool IsDirectory,
|
||||
bool IsEmptyDirectory, long Length);
|
||||
|
||||
internal static class FileSystemUtilities
|
||||
public static class FileSystemUtilities
|
||||
{
|
||||
public static void CopyDirectory(string source, string destination,
|
||||
CancellationToken cancellationToken = default)
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WiiCompiled.Setup.Linux;
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>Reads and atomically writes the small JSON state documents this tool keeps.</summary>
|
||||
internal static class JsonState
|
||||
/// <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 };
|
||||
@@ -17,7 +17,7 @@ internal static class JsonState
|
||||
catch
|
||||
{
|
||||
// A truncated or hand-edited state document must degrade into "unknown", which every
|
||||
// caller already treats as "assume stale and re-run install", not into a crash.
|
||||
// caller already treats as "assume stale and rebuild", not into a crash.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace WiiCompiled.NodTool;
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>Disc metadata parsed from `nodtool info`'s stdout.</summary>
|
||||
public sealed record NodToolDiscInfo(string GameId, string Title, int Revision);
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WiiCompiled.NodTool;
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the `nodtool` binary both installers use for Wii disc validation/extraction (see
|
||||
@@ -9,7 +9,7 @@ namespace WiiCompiled.NodTool;
|
||||
/// 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.NodTool.Cli (invoked once at packaging time by both
|
||||
/// 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
|
||||
+6
-54
@@ -1,4 +1,4 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>
|
||||
/// A portable installation is a self-contained directory tree the user can move or carry on removable media:
|
||||
@@ -9,8 +9,11 @@ namespace WiiCompiled.Setup;
|
||||
/// </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>
|
||||
internal static class PortableRoot
|
||||
public static class PortableRoot
|
||||
{
|
||||
public const string MarkerFileName = "portable.txt";
|
||||
public const string UserDataDirectoryName = "UserData";
|
||||
@@ -72,7 +75,7 @@ internal static class PortableRoot
|
||||
if (!File.Exists(marker))
|
||||
{
|
||||
File.WriteAllText(marker,
|
||||
$"{ProductInfo.Name} portable installation." + Environment.NewLine +
|
||||
"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." +
|
||||
@@ -90,54 +93,3 @@ internal static class PortableRoot
|
||||
|
||||
private static string Normalize(string path) => FileSystemUtilities.NormalizePath(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A portable root can be moved or renamed between operations. Every installed-host operation that
|
||||
/// reads <c>install-state.json</c> passes through here first so exactly one place decides what a
|
||||
/// moved installation means, and so a non-portable installation is never touched.
|
||||
/// </summary>
|
||||
internal static class PortableInstallHealing
|
||||
{
|
||||
/// <summary>
|
||||
/// Reconciles a moved portable installation with its recorded location: the state file adopts the
|
||||
/// directory it was actually found in, and the native build tree is discarded because its
|
||||
/// CMake cache holds absolute paths from the old location. Returns whether anything was healed.
|
||||
/// </summary>
|
||||
public static bool HealMovedInstall(Installation installation, IInstallReporter? reporter = null)
|
||||
{
|
||||
// Guard: an ordinary installation that disagrees with its state file is a real problem for
|
||||
// the operation to report, not something to silently rewrite.
|
||||
if (PortableRoot.TryFind(installation.Root) is null) return false;
|
||||
|
||||
var state = installation.ReadInstallState();
|
||||
if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return false;
|
||||
|
||||
string recorded;
|
||||
try
|
||||
{
|
||||
recorded = FileSystemUtilities.NormalizePath(state.InstallDir);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
recorded = state.InstallDir;
|
||||
}
|
||||
if (recorded.Equals(installation.Root, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
var previous = state.InstallDir;
|
||||
state.InstallDir = installation.Root;
|
||||
JsonState.Write(installation.InstallStatePath, state);
|
||||
|
||||
// The configured native build directory bakes absolute source, toolchain, and output paths
|
||||
// into CMakeCache.txt. After a move it is unusable and would fail the next configure rather
|
||||
// than being reused, so it is removed and reconfigured from scratch on the next build.
|
||||
var nativeBuild = Path.Combine(installation.WorkspaceDirectory, "native-build");
|
||||
var hadNativeBuild = Directory.Exists(nativeBuild);
|
||||
if (hadNativeBuild) FileSystemUtilities.DeleteDirectoryIfExists(nativeBuild);
|
||||
|
||||
reporter?.Diagnostic(
|
||||
$"This portable installation moved from {previous} to {installation.Root}. " +
|
||||
"The recorded location was updated" +
|
||||
(hadNativeBuild ? " and the location-bound native build cache was discarded." : "."));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -1,16 +1,17 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the one canonical Retro Rewind install. Wheel Wizard owns and passes it as
|
||||
/// <c>--retro-dir</c>; the backend only resolves, reads, and records it, never packages or copies it.
|
||||
/// <c>--retro-dir</c>; each installer only resolves, reads, and records it, never packages or
|
||||
/// copies it.
|
||||
/// </summary>
|
||||
internal static class RetroRewindSource
|
||||
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>
|
||||
internal static string ResolveRetroRewind6(string selected)
|
||||
public static string ResolveRetroRewind6(string selected)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selected))
|
||||
throw new InvalidDataException("Choose the canonical Retro Rewind folder.");
|
||||
+17
-125
@@ -1,21 +1,28 @@
|
||||
using System.Diagnostics;
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using WiiCompiled.NodTool;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
internal static class InputValidation
|
||||
/// <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);
|
||||
private static readonly HashSet<string> SupportedDiscImageExtensions = new(
|
||||
[".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz"],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public const string CurrentRetroWfcPayloadUri = "http://nas.play.rwfc.net/payload?g=RMCPD00";
|
||||
private static readonly string RetroWfcOfflinePayloadFile =
|
||||
@@ -38,57 +45,6 @@ internal static class InputValidation
|
||||
private const int RetroWfcPayloadSignatureOffset = 0x10;
|
||||
private const int RetroWfcPayloadMinimumBytes = 0x130;
|
||||
|
||||
public static void ValidateExtension(string gamePath)
|
||||
{
|
||||
if (!File.Exists(gamePath))
|
||||
throw new FileNotFoundException("The selected game image does not exist.", gamePath);
|
||||
var extension = Path.GetExtension(gamePath);
|
||||
if (!SupportedDiscImageExtensions.Contains(extension))
|
||||
throw new InvalidDataException(
|
||||
"Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format.");
|
||||
}
|
||||
|
||||
public static async Task<DiscHeader> ReadDiscHeaderAsync(string nodTool, string gamePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateExtension(gamePath);
|
||||
var result = await ProcessRunner.RunAsync(nodTool,
|
||||
["info", Path.GetFullPath(gamePath)], null, cancellationToken);
|
||||
if (result.ExitCode != 0)
|
||||
throw new InvalidDataException("nodtool could not read this disc image. " + result.CombinedOutput.Trim());
|
||||
|
||||
var info = NodToolInfoParser.Parse(result.StandardOutput);
|
||||
return new DiscHeader
|
||||
{
|
||||
GameId = info.GameId,
|
||||
InternalName = info.Title,
|
||||
Region = RegionFromGameId(info.GameId),
|
||||
Revision = info.Revision,
|
||||
};
|
||||
}
|
||||
|
||||
private static string RegionFromGameId(string gameId) => gameId.Length >= 4
|
||||
? gameId[3] switch
|
||||
{
|
||||
'P' => "PAL",
|
||||
'E' => "NTSC-U",
|
||||
'J' => "NTSC-J",
|
||||
'K' => "Korea",
|
||||
'W' => "Taiwan",
|
||||
_ => gameId[3].ToString(),
|
||||
}
|
||||
: "Unknown";
|
||||
|
||||
public static void EnsureCompatibleDisc(DiscHeader header, PayloadManifest manifest)
|
||||
{
|
||||
if (!header.GameId.Equals(manifest.ExpectedGameId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"This build supports Mario Kart Wii PAL ({manifest.ExpectedGameId}). " +
|
||||
$"The selected image is {header.GameId} ({header.InternalName}, {header.Region}).");
|
||||
}
|
||||
}
|
||||
|
||||
public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory,
|
||||
RSAParameters? signingKey = null)
|
||||
{
|
||||
@@ -201,7 +157,7 @@ internal static class InputValidation
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsTransientRetroWfcDownloadFailure(Exception exception,
|
||||
public static bool IsTransientRetroWfcDownloadFailure(Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) return false;
|
||||
@@ -247,73 +203,9 @@ internal static class InputValidation
|
||||
"The Retro-WFC payload is not signed by the pinned Retro-WFC signing key.");
|
||||
}
|
||||
|
||||
public static string Sha256File(string path)
|
||||
private static string Sha256File(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError)
|
||||
{
|
||||
public string CombinedOutput => StandardOutput + Environment.NewLine + StandardError;
|
||||
}
|
||||
|
||||
internal static class ProcessRunner
|
||||
{
|
||||
/// <summary>Runs a redirected child process to completion. <paramref name="configure"/> sets up a
|
||||
/// working directory or scrubbed environment; <paramref name="capture"/> is off for callers that only
|
||||
/// forward output live, so a build's output isn't buffered in memory for nobody to read.</summary>
|
||||
public static async Task<ProcessResult> RunAsync(string executable, IReadOnlyList<string> arguments,
|
||||
Action<string>? output, CancellationToken cancellationToken,
|
||||
Action<ProcessStartInfo>? configure = null, bool capture = true,
|
||||
Action<Exception>? onTerminationFailure = null)
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
foreach (var argument in arguments) info.ArgumentList.Add(argument);
|
||||
configure?.Invoke(info);
|
||||
|
||||
using var process = new Process { StartInfo = info, EnableRaisingEvents = true };
|
||||
var stdout = new List<string>();
|
||||
var stderr = new List<string>();
|
||||
process.OutputDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stdout.Add(e.Data); output?.Invoke(e.Data); } };
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stderr.Add(e.Data); output?.Invoke(e.Data); } };
|
||||
if (!process.Start()) throw new InvalidOperationException($"Could not start {executable}.");
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
await WaitForExitAsync(process, cancellationToken, onTerminationFailure);
|
||||
return new ProcessResult(process.ExitCode, string.Join(Environment.NewLine, stdout),
|
||||
string.Join(Environment.NewLine, stderr));
|
||||
}
|
||||
|
||||
public static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken,
|
||||
Action<Exception>? onTerminationFailure = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited) process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onTerminationFailure?.Invoke(ex);
|
||||
}
|
||||
await process.WaitForExitAsync(CancellationToken.None);
|
||||
process.WaitForExit();
|
||||
throw;
|
||||
}
|
||||
process.WaitForExit();
|
||||
}
|
||||
}
|
||||
+18
-16
@@ -1,16 +1,19 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Common;
|
||||
|
||||
internal sealed record RuntimeConfigSnapshot(bool Existed, byte[] Contents);
|
||||
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"/>.
|
||||
/// 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>
|
||||
internal static class RuntimeConfiguration
|
||||
public static class RuntimeConfiguration
|
||||
{
|
||||
public const string ConfigFileName = "Config.toml";
|
||||
|
||||
@@ -51,7 +54,7 @@ internal static class RuntimeConfiguration
|
||||
File.Delete(configPath);
|
||||
}
|
||||
|
||||
internal static void SetDvdRoot(string configPath, string dvdRoot) =>
|
||||
public static void SetDvdRoot(string configPath, string dvdRoot) =>
|
||||
SetPath(configPath, "dvd_root", dvdRoot);
|
||||
|
||||
/// <summary>
|
||||
@@ -59,21 +62,21 @@ internal static class RuntimeConfiguration
|
||||
/// 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>
|
||||
internal static void SetRetroRewindRoot(string configPath, string retroRewindRoot) =>
|
||||
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");
|
||||
|
||||
internal static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) =>
|
||||
public static void RemoveRetroRewindRootIfOwned(string configPath, string retroRewindRoot) =>
|
||||
RemovePathIfOwned(configPath, "retro_rewind_root", retroRewindRoot);
|
||||
|
||||
internal static void RemoveDvdRootIfOwned(string configPath, string dvdRoot) =>
|
||||
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>
|
||||
internal static string? GetPath(string configPath, string key) =>
|
||||
public static string? GetPath(string configPath, string key) =>
|
||||
TryUnquoteToml(GetRawValue(configPath, "paths", key) ?? "", out var value) ? value : null;
|
||||
|
||||
/// <summary>
|
||||
@@ -81,17 +84,17 @@ internal static class RuntimeConfiguration
|
||||
/// <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>
|
||||
internal static string? GetResolvedPath(string configPath, string key)
|
||||
public static string? GetResolvedPath(string configPath, string key)
|
||||
{
|
||||
var stored = GetPath(configPath, key);
|
||||
return string.IsNullOrWhiteSpace(stored) ? null : ResolveAgainstConfig(configPath, stored);
|
||||
}
|
||||
|
||||
internal static string ConfigDirectory(string configPath) =>
|
||||
public static string ConfigDirectory(string configPath) =>
|
||||
Path.GetDirectoryName(Path.GetFullPath(configPath))
|
||||
?? throw new InvalidOperationException($"{configPath} has no containing directory.");
|
||||
|
||||
internal static string ResolveAgainstConfig(string configPath, string value) =>
|
||||
public static string ResolveAgainstConfig(string configPath, string value) =>
|
||||
Path.GetFullPath(value, ConfigDirectory(configPath));
|
||||
|
||||
private static void SetPath(string configPath, string key, string value)
|
||||
@@ -150,7 +153,7 @@ internal static class RuntimeConfiguration
|
||||
}
|
||||
|
||||
/// <summary>The raw TOML literal stored for a key, or null when the section or key is absent.</summary>
|
||||
internal static string? GetRawValue(string configPath, string section, string key)
|
||||
public static string? GetRawValue(string configPath, string section, string key)
|
||||
{
|
||||
if (!File.Exists(configPath)) return null;
|
||||
var header = $"[{section}]";
|
||||
@@ -265,7 +268,7 @@ internal static class RuntimeConfiguration
|
||||
private static string QuoteToml(string value) =>
|
||||
"\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
||||
|
||||
internal static bool TryUnquoteToml(string value, out string result)
|
||||
public static bool TryUnquoteToml(string value, out string result)
|
||||
{
|
||||
result = "";
|
||||
if (value.Length < 2) return false;
|
||||
@@ -292,5 +295,4 @@ internal static class RuntimeConfiguration
|
||||
result = builder.ToString();
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
+3
-3
@@ -3,12 +3,12 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>WiiCompiled.NodTool</RootNamespace>
|
||||
<AssemblyName>WiiCompiled.NodTool</AssemblyName>
|
||||
<RootNamespace>WiiCompiled.Setup.Common</RootNamespace>
|
||||
<AssemblyName>WiiCompiled.Setup.Common</AssemblyName>
|
||||
<Version>0.2.22</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
<Product>WiiCompiled</Product>
|
||||
<Description>Shared nodtool acquisition/parsing logic used by both the Windows and Linux installers</Description>
|
||||
<Description>Shared nodtool/Retro-WFC-payload logic used by both the Windows and Linux installers</Description>
|
||||
<DebugType>embedded</DebugType>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
@@ -11,7 +11,7 @@ internal static class BuildRunner
|
||||
{
|
||||
public static async Task RunAsync(
|
||||
string workspace, string profile, string outputDir, string? baseOutputDir,
|
||||
string? retroRewindPackageDir, string? retroWfcOfflineDir, bool skipRetroWfcPayload,
|
||||
string? retroDir, string? retroWfcOfflineDir, bool skipRetroWfcPayload,
|
||||
bool forceCleanBuild, string? translatorBin, IInstallReporter reporter, CancellationToken cancellationToken)
|
||||
{
|
||||
var script = Path.Combine(workspace, "Launcher", "local-build.sh");
|
||||
@@ -31,9 +31,11 @@ internal static class BuildRunner
|
||||
{
|
||||
startInfo.ArgumentList.Add("--base-output-dir"); startInfo.ArgumentList.Add(baseOutputDir);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(retroRewindPackageDir))
|
||||
if (!string.IsNullOrEmpty(retroDir))
|
||||
{
|
||||
startInfo.ArgumentList.Add("--retro-rewind-package-dir"); startInfo.ArgumentList.Add(retroRewindPackageDir);
|
||||
// Still forwarded to local-build.sh under its own internal name -
|
||||
// --retro-rewind-package-dir - matching LocalBuild.ps1's own -RetroRewindPackageDirectory.
|
||||
startInfo.ArgumentList.Add("--retro-rewind-package-dir"); startInfo.ArgumentList.Add(retroDir);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(retroWfcOfflineDir))
|
||||
{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Security.Cryptography;
|
||||
using WiiCompiled.NodTool;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Linux;
|
||||
|
||||
/// <summary>
|
||||
/// Validates and extracts the user's own Mario Kart Wii disc via `nodtool` (see
|
||||
/// WiiCompiled.NodTool/NodToolProvider.cs) - a prebuilt, MIT/Apache-2.0-licensed CLI from
|
||||
/// WiiCompiled.Setup.Common/NodToolProvider.cs) - a prebuilt, MIT/Apache-2.0-licensed CLI from
|
||||
/// encounter/nod, replacing the earlier dependency on a system-installed `dolphin-tool`
|
||||
/// (GPL-2.0-or-later, and not reliably packaged standalone by every distro).
|
||||
/// </summary>
|
||||
@@ -30,42 +30,38 @@ internal static class DiscTool
|
||||
"Only your own legally-owned copy of that exact game/region can be used.");
|
||||
}
|
||||
|
||||
// Extracted straight into Assets/DATA (kept, not a scratch dir) - the runtime reads course/
|
||||
// texture/audio data from this directory live via [paths] dvd_root, not just at translation
|
||||
// time, so it has to survive past this install (see Program.cs, which points dvd_root here).
|
||||
reporter.Progress(InstallStages.ExtractDisc, "Extracting the disc image", 4);
|
||||
var scratch = Path.Combine(Path.GetTempPath(), "wiicompiled-disc-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(scratch);
|
||||
try
|
||||
var dataDir = Path.Combine(assetsDirectory, "DATA");
|
||||
if (Directory.Exists(dataDir)) Directory.Delete(dataDir, recursive: true);
|
||||
await RunExtractAsync(nodTool, isoPath, dataDir, cancellationToken);
|
||||
|
||||
var dolPath = Path.Combine(dataDir, "sys", "main.dol");
|
||||
var relPath = Path.Combine(dataDir, "files", "rel", "StaticR.rel");
|
||||
if (!File.Exists(dolPath)) throw new FileNotFoundException("nodtool did not produce main.dol", dolPath);
|
||||
if (!File.Exists(relPath)) throw new FileNotFoundException("nodtool did not produce StaticR.rel", relPath);
|
||||
|
||||
var dolSha = Sha256Of(dolPath);
|
||||
var relSha = Sha256Of(relPath);
|
||||
if (!string.Equals(dolSha, manifest.DolSha256, StringComparison.Ordinal))
|
||||
{
|
||||
await RunExtractAsync(nodTool, isoPath, scratch, cancellationToken);
|
||||
|
||||
var dolPath = Path.Combine(scratch, "sys", "main.dol");
|
||||
var relPath = Path.Combine(scratch, "files", "rel", "StaticR.rel");
|
||||
if (!File.Exists(dolPath)) throw new FileNotFoundException("nodtool did not produce main.dol", dolPath);
|
||||
if (!File.Exists(relPath)) throw new FileNotFoundException("nodtool did not produce StaticR.rel", relPath);
|
||||
|
||||
var dolSha = Sha256Of(dolPath);
|
||||
var relSha = Sha256Of(relPath);
|
||||
if (!string.Equals(dolSha, manifest.DolSha256, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"main.dol sha256 mismatch: expected {manifest.DolSha256}, got {dolSha}. " +
|
||||
"This disc revision does not match what this project's manifest is pinned to.");
|
||||
}
|
||||
if (!string.Equals(relSha, manifest.RelSha256, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"StaticR.rel sha256 mismatch: expected {manifest.RelSha256}, got {relSha}. " +
|
||||
"This disc revision does not match what this project's manifest is pinned to.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(assetsDirectory);
|
||||
File.Copy(dolPath, Path.Combine(assetsDirectory, "main.dol"), overwrite: true);
|
||||
File.Copy(relPath, Path.Combine(assetsDirectory, "StaticR.rel"), overwrite: true);
|
||||
reporter.Progress(InstallStages.ExtractDisc, "Disc validated and extracted", 6);
|
||||
throw new InvalidOperationException(
|
||||
$"main.dol sha256 mismatch: expected {manifest.DolSha256}, got {dolSha}. " +
|
||||
"This disc revision does not match what this project's manifest is pinned to.");
|
||||
}
|
||||
finally
|
||||
if (!string.Equals(relSha, manifest.RelSha256, StringComparison.Ordinal))
|
||||
{
|
||||
try { Directory.Delete(scratch, recursive: true); } catch { /* best-effort cleanup */ }
|
||||
throw new InvalidOperationException(
|
||||
$"StaticR.rel sha256 mismatch: expected {manifest.RelSha256}, got {relSha}. " +
|
||||
"This disc revision does not match what this project's manifest is pinned to.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(assetsDirectory);
|
||||
File.Copy(dolPath, Path.Combine(assetsDirectory, "main.dol"), overwrite: true);
|
||||
File.Copy(relPath, Path.Combine(assetsDirectory, "StaticR.rel"), overwrite: true);
|
||||
reporter.Progress(InstallStages.ExtractDisc, "Disc validated and extracted", 6);
|
||||
}
|
||||
|
||||
private static async Task<string> RunInfoAsync(string nodTool, string isoPath, CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Linux;
|
||||
|
||||
@@ -40,7 +41,7 @@ internal static class Program
|
||||
await InstallAsync(flags, reporter, cts.Token);
|
||||
break;
|
||||
case "uninstall":
|
||||
Uninstall(flags);
|
||||
Uninstall();
|
||||
break;
|
||||
case "launch-base":
|
||||
return Launch("base", flags);
|
||||
@@ -73,9 +74,25 @@ internal static class Program
|
||||
|
||||
private static async Task InstallAsync(Dictionary<string, string?> flags, IInstallReporter reporter, CancellationToken token)
|
||||
{
|
||||
var profile = flags.GetValueOrDefault("profile") ?? "base";
|
||||
if (profile is not ("base" or "retro-rewind" or "both"))
|
||||
throw new ArgumentException("--profile must be base, retro-rewind, or both");
|
||||
var retroDir = flags.GetValueOrDefault("retro-dir");
|
||||
var installsRetro = !string.IsNullOrEmpty(retroDir);
|
||||
var downloadPayload = flags.ContainsKey("download-retro-wfc-payload");
|
||||
var skipPayload = flags.ContainsKey("skip-retro-wfc-payload");
|
||||
if (installsRetro)
|
||||
{
|
||||
if (downloadPayload == skipPayload)
|
||||
throw new ArgumentException(
|
||||
"Choose exactly one Retro-WFC mode: --download-retro-wfc-payload or --skip-retro-wfc-payload.");
|
||||
}
|
||||
else if (downloadPayload || skipPayload)
|
||||
{
|
||||
throw new ArgumentException("A Retro-WFC payload option is valid only with --retro-dir.");
|
||||
}
|
||||
|
||||
// Canonicalizes to the exact RetroRewind6 folder (accepting a parent folder or a symlink),
|
||||
// the same validation Windows applies via this same shared method - local-build.sh's own
|
||||
// check further down is a simpler backstop, not the primary validation anymore.
|
||||
if (installsRetro) retroDir = RetroRewindSource.ResolveRetroRewind6(retroDir!);
|
||||
|
||||
var workspace = flags.GetValueOrDefault("workspace") ?? WorkspaceLocator.FindFrom(AppContext.BaseDirectory);
|
||||
var manifest = ProjectManifest.Load(Path.Combine(workspace, "projects", "mkwii", "recomp.yml"));
|
||||
@@ -102,15 +119,35 @@ internal static class Program
|
||||
var state = JsonState.TryRead<InstallState>(StatePath) ?? new InstallState { Workspace = workspace };
|
||||
state.Workspace = workspace;
|
||||
|
||||
var profiles = profile == "both" ? new[] { "base", "retro-rewind" } : new[] { profile };
|
||||
var baseInstallDir = profile == "both" ? DefaultInstallDir("base") : null;
|
||||
var installDir = flags.GetValueOrDefault("install-dir") ?? DefaultInstallDir(profile == "both" ? "retro-rewind" : profile);
|
||||
var profile = installsRetro ? "both" : "base";
|
||||
var profiles = installsRetro ? new[] { "base", "retro-rewind" } : new[] { "base" };
|
||||
var baseInstallDir = installsRetro ? DefaultInstallDir("base") : null;
|
||||
var installDir = flags.GetValueOrDefault("install-dir") ?? DefaultInstallDir(installsRetro ? "retro-rewind" : "base");
|
||||
|
||||
string? retroWfcOfflineDir = null;
|
||||
if (downloadPayload)
|
||||
{
|
||||
// Reused if a previous install already downloaded and it's still valid - matches
|
||||
// Windows's own reuse-if-valid behavior instead of re-downloading on every install.
|
||||
var cacheDir = Path.Combine(workspace, "generated", "retro-wfc-payload");
|
||||
reporter.Progress(InstallStages.Validate, "Preparing the Retro-WFC payload", 1);
|
||||
try
|
||||
{
|
||||
RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(cacheDir);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
await RetroWfcPayload.DownloadRetroWfcPayloadAsync(
|
||||
RetroWfcPayload.CurrentRetroWfcPayloadUri, cacheDir, token);
|
||||
}
|
||||
retroWfcOfflineDir = cacheDir;
|
||||
}
|
||||
|
||||
await BuildRunner.RunAsync(
|
||||
workspace, profile, installDir, baseInstallDir,
|
||||
flags.GetValueOrDefault("retro-rewind-package-dir"),
|
||||
flags.GetValueOrDefault("retro-wfc-offline-dir"),
|
||||
flags.ContainsKey("skip-retro-wfc-payload"),
|
||||
retroDir,
|
||||
retroWfcOfflineDir,
|
||||
skipPayload,
|
||||
flags.ContainsKey("force-clean-build"),
|
||||
flags.GetValueOrDefault("translator-bin"),
|
||||
reporter, token);
|
||||
@@ -137,15 +174,32 @@ internal static class Program
|
||||
DesktopEntry.Create(p, displayName, Path.Combine(dir, exeName));
|
||||
}
|
||||
JsonState.Write(StatePath, state);
|
||||
|
||||
// The runtime reads course/texture/audio data live from dvd_root at every launch, not just
|
||||
// at translation time - without this the game fatally errors the instant it needs any file
|
||||
// that isn't main.dol/StaticR.rel. Linux has no --portable flag, so this is always the
|
||||
// per-user Config.toml (RuntimeConfiguration.ResolveConfigPath's Windows-only portable-root
|
||||
// lookup has nothing to find here either way).
|
||||
var configPath = RuntimeConfiguration.ApplicationDataConfigPath;
|
||||
var dataDir = Path.Combine(assetsDir, "DATA");
|
||||
if (Directory.Exists(dataDir))
|
||||
{
|
||||
RuntimeConfiguration.SetDvdRoot(configPath, dataDir);
|
||||
}
|
||||
if (installsRetro)
|
||||
{
|
||||
RuntimeConfiguration.SetRetroRewindRoot(configPath, retroDir!);
|
||||
}
|
||||
|
||||
reporter.Progress(InstallStages.Shortcuts, "Install complete", 99);
|
||||
}
|
||||
|
||||
private static void Uninstall(Dictionary<string, string?> flags)
|
||||
private static void Uninstall()
|
||||
{
|
||||
var profile = flags.GetValueOrDefault("profile") ?? "all";
|
||||
// Matches Windows: UninstallService.cs removes the whole install directory unconditionally -
|
||||
// there is no partial-product uninstall on either platform.
|
||||
var state = JsonState.TryRead<InstallState>(StatePath) ?? new InstallState();
|
||||
var toRemove = profile == "all" ? state.Products.ToList() : state.Products.Where(r => r.Profile == profile).ToList();
|
||||
foreach (var record in toRemove)
|
||||
foreach (var record in state.Products.ToList())
|
||||
{
|
||||
if (Directory.Exists(record.InstallDirectory))
|
||||
{
|
||||
@@ -260,11 +314,11 @@ internal static class Program
|
||||
Console.WriteLine("""
|
||||
Usage: wiicompiled-setup <command> [options]
|
||||
|
||||
install --profile {base|retro-rewind|both} [--game ISO_PATH] [--install-dir DIR]
|
||||
[--retro-rewind-package-dir DIR] [--retro-wfc-offline-dir DIR | --skip-retro-wfc-payload]
|
||||
install [--game ISO_PATH] [--install-dir DIR] [--retro-dir DIR
|
||||
{--download-retro-wfc-payload | --skip-retro-wfc-payload}]
|
||||
[--force-clean-build] [--translator-bin PATH] [--disc-tool-bin PATH]
|
||||
[--progress-json] [--workspace DIR]
|
||||
uninstall [--profile {base|retro-rewind|both|all}]
|
||||
uninstall
|
||||
launch-base
|
||||
launch-retro
|
||||
check-products
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WiiCompiled.NodTool\WiiCompiled.NodTool.csproj" />
|
||||
<ProjectReference Include="..\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a frontend-owned, named Windows event into the cancellation token used by setup.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal enum AppMode
|
||||
{
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal sealed record RetroRewindCompileInputs(
|
||||
string RetroRewindRoot,
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal static class ConsoleCommands
|
||||
{
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal static class GameLaunchService
|
||||
{
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Diagnostics;
|
||||
using System.Buffers.Binary;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal static class InputValidation
|
||||
{
|
||||
private static readonly HashSet<string> SupportedDiscImageExtensions = new(
|
||||
[".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz"],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static void ValidateExtension(string gamePath)
|
||||
{
|
||||
if (!File.Exists(gamePath))
|
||||
throw new FileNotFoundException("The selected game image does not exist.", gamePath);
|
||||
var extension = Path.GetExtension(gamePath);
|
||||
if (!SupportedDiscImageExtensions.Contains(extension))
|
||||
throw new InvalidDataException(
|
||||
"Select a complete Wii disc image in ISO, GCM, GCZ, CISO, WBFS, WIA, or RVZ format.");
|
||||
}
|
||||
|
||||
public static async Task<DiscHeader> ReadDiscHeaderAsync(string nodTool, string gamePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateExtension(gamePath);
|
||||
var result = await ProcessRunner.RunAsync(nodTool,
|
||||
["info", Path.GetFullPath(gamePath)], null, cancellationToken);
|
||||
if (result.ExitCode != 0)
|
||||
throw new InvalidDataException("nodtool could not read this disc image. " + result.CombinedOutput.Trim());
|
||||
|
||||
var info = NodToolInfoParser.Parse(result.StandardOutput);
|
||||
return new DiscHeader
|
||||
{
|
||||
GameId = info.GameId,
|
||||
InternalName = info.Title,
|
||||
Region = RegionFromGameId(info.GameId),
|
||||
Revision = info.Revision,
|
||||
};
|
||||
}
|
||||
|
||||
private static string RegionFromGameId(string gameId) => gameId.Length >= 4
|
||||
? gameId[3] switch
|
||||
{
|
||||
'P' => "PAL",
|
||||
'E' => "NTSC-U",
|
||||
'J' => "NTSC-J",
|
||||
'K' => "Korea",
|
||||
'W' => "Taiwan",
|
||||
_ => gameId[3].ToString(),
|
||||
}
|
||||
: "Unknown";
|
||||
|
||||
public static void EnsureCompatibleDisc(DiscHeader header, PayloadManifest manifest)
|
||||
{
|
||||
if (!header.GameId.Equals(manifest.ExpectedGameId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"This build supports Mario Kart Wii PAL ({manifest.ExpectedGameId}). " +
|
||||
$"The selected image is {header.GameId} ({header.InternalName}, {header.Region}).");
|
||||
}
|
||||
}
|
||||
|
||||
// Thin forwarding wrappers: the actual download/RSA-verification logic lives in
|
||||
// WiiCompiled.Setup.Common.RetroWfcPayload (shared with WiiCompiled.Setup.Linux) so there's one
|
||||
// copy of it, not two. Kept under these names so every existing call site here
|
||||
// (ProductRepairService.cs, LocalBuildService.cs, Installation.cs, SelfTests.cs) is unchanged.
|
||||
public const string CurrentRetroWfcPayloadUri = RetroWfcPayload.CurrentRetroWfcPayloadUri;
|
||||
|
||||
public static string ValidateStagedRetroWfcPayloadDirectory(string stagedDirectory,
|
||||
RSAParameters? signingKey = null) =>
|
||||
RetroWfcPayload.ValidateStagedRetroWfcPayloadDirectory(stagedDirectory, signingKey);
|
||||
|
||||
public static string ResolveRetroWfcPayloadFile(string stagedDirectory,
|
||||
RSAParameters? signingKey = null) =>
|
||||
RetroWfcPayload.ResolveRetroWfcPayloadFile(stagedDirectory, signingKey);
|
||||
|
||||
public static string ComputeRetroWfcPayloadSha256(string stagedDirectory,
|
||||
RSAParameters? signingKey = null) =>
|
||||
RetroWfcPayload.ComputeRetroWfcPayloadSha256(stagedDirectory, signingKey);
|
||||
|
||||
public static void ValidateRetroWfcPayloadUri(string uriText) =>
|
||||
RetroWfcPayload.ValidateRetroWfcPayloadUri(uriText);
|
||||
|
||||
public static Task<RetroWfcPayloadSnapshot> DownloadRetroWfcPayloadAsync(string uriText,
|
||||
string destinationDirectory, CancellationToken cancellationToken) =>
|
||||
RetroWfcPayload.DownloadRetroWfcPayloadAsync(uriText, destinationDirectory, cancellationToken);
|
||||
|
||||
internal static bool IsTransientRetroWfcDownloadFailure(Exception exception,
|
||||
CancellationToken cancellationToken) =>
|
||||
RetroWfcPayload.IsTransientRetroWfcDownloadFailure(exception, cancellationToken);
|
||||
|
||||
public static string Sha256File(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError)
|
||||
{
|
||||
public string CombinedOutput => StandardOutput + Environment.NewLine + StandardError;
|
||||
}
|
||||
|
||||
internal static class ProcessRunner
|
||||
{
|
||||
/// <summary>Runs a redirected child process to completion. <paramref name="configure"/> sets up a
|
||||
/// working directory or scrubbed environment; <paramref name="capture"/> is off for callers that only
|
||||
/// forward output live, so a build's output isn't buffered in memory for nobody to read.</summary>
|
||||
public static async Task<ProcessResult> RunAsync(string executable, IReadOnlyList<string> arguments,
|
||||
Action<string>? output, CancellationToken cancellationToken,
|
||||
Action<ProcessStartInfo>? configure = null, bool capture = true,
|
||||
Action<Exception>? onTerminationFailure = null)
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
foreach (var argument in arguments) info.ArgumentList.Add(argument);
|
||||
configure?.Invoke(info);
|
||||
|
||||
using var process = new Process { StartInfo = info, EnableRaisingEvents = true };
|
||||
var stdout = new List<string>();
|
||||
var stderr = new List<string>();
|
||||
process.OutputDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stdout.Add(e.Data); output?.Invoke(e.Data); } };
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) { if (capture) stderr.Add(e.Data); output?.Invoke(e.Data); } };
|
||||
if (!process.Start()) throw new InvalidOperationException($"Could not start {executable}.");
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
await WaitForExitAsync(process, cancellationToken, onTerminationFailure);
|
||||
return new ProcessResult(process.ExitCode, string.Join(Environment.NewLine, stdout),
|
||||
string.Join(Environment.NewLine, stderr));
|
||||
}
|
||||
|
||||
public static async Task WaitForExitAsync(Process process, CancellationToken cancellationToken,
|
||||
Action<Exception>? onTerminationFailure = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited) process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onTerminationFailure?.Invoke(ex);
|
||||
}
|
||||
await process.WaitForExitAsync(CancellationToken.None);
|
||||
process.WaitForExit();
|
||||
throw;
|
||||
}
|
||||
process.WaitForExit();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// A fail-fast, cross-process lock covering install, repair and launch operations for one install
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Stable stage identifiers reported by <c>--progress-json</c>. These are part of the public
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Owns one temporary directory for an install operation. The name carries the installation's scope
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal enum InstallTransactionEntryKind
|
||||
{
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>Provenance written by the bundled build script next to every product it produces.</summary>
|
||||
internal sealed class LocalBuildProvenance
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Names of the installed/staged layout. Not cosmetic: payload and toolkit identities hash relative paths
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal sealed class InstallerEngine
|
||||
{
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Both"/> runs one retro-aware translation and compiles the two products from a single
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal enum RetroWfcPayloadMode
|
||||
{
|
||||
@@ -54,12 +55,6 @@ internal sealed class PayloadManifest
|
||||
public string NativeToolchainFingerprint { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <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>
|
||||
internal sealed record RetroWfcPayloadSnapshot(string Directory, string Sha256, long ByteLength);
|
||||
|
||||
internal sealed class DiscHeader
|
||||
{
|
||||
[JsonPropertyName("game_id")]
|
||||
+1
-1
@@ -2,7 +2,7 @@ using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal sealed class PayloadArchive : IDisposable
|
||||
{
|
||||
@@ -0,0 +1,54 @@
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// A portable root can be moved or renamed between operations. Every installed-host operation that
|
||||
/// reads <c>install-state.json</c> passes through here first so exactly one place decides what a
|
||||
/// moved installation means, and so a non-portable installation is never touched.
|
||||
/// </summary>
|
||||
internal static class PortableInstallHealing
|
||||
{
|
||||
/// <summary>
|
||||
/// Reconciles a moved portable installation with its recorded location: the state file adopts the
|
||||
/// directory it was actually found in, and the native build tree is discarded because its
|
||||
/// CMake cache holds absolute paths from the old location. Returns whether anything was healed.
|
||||
/// </summary>
|
||||
public static bool HealMovedInstall(Installation installation, IInstallReporter? reporter = null)
|
||||
{
|
||||
// Guard: an ordinary installation that disagrees with its state file is a real problem for
|
||||
// the operation to report, not something to silently rewrite.
|
||||
if (PortableRoot.TryFind(installation.Root) is null) return false;
|
||||
|
||||
var state = installation.ReadInstallState();
|
||||
if (state is not { SchemaVersion: 1 } || string.IsNullOrWhiteSpace(state.InstallDir)) return false;
|
||||
|
||||
string recorded;
|
||||
try
|
||||
{
|
||||
recorded = FileSystemUtilities.NormalizePath(state.InstallDir);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
recorded = state.InstallDir;
|
||||
}
|
||||
if (recorded.Equals(installation.Root, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
var previous = state.InstallDir;
|
||||
state.InstallDir = installation.Root;
|
||||
JsonState.Write(installation.InstallStatePath, state);
|
||||
|
||||
// The configured native build directory bakes absolute source, toolchain, and output paths
|
||||
// into CMakeCache.txt. After a move it is unusable and would fail the next configure rather
|
||||
// than being reused, so it is removed and reconfigured from scratch on the next build.
|
||||
var nativeBuild = Path.Combine(installation.WorkspaceDirectory, "native-build");
|
||||
var hadNativeBuild = Directory.Exists(nativeBuild);
|
||||
if (hadNativeBuild) FileSystemUtilities.DeleteDirectoryIfExists(nativeBuild);
|
||||
|
||||
reporter?.Diagnostic(
|
||||
$"This portable installation moved from {previous} to {installation.Root}. " +
|
||||
"The recorded location was updated" +
|
||||
(hadNativeBuild ? " and the location-bound native build cache was discarded." : "."));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles installed products against the canonical Retro Rewind install Wheel Wizard owns: the
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Refuses to replace installed products while one of them is running: publishing renames the
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// The one path by which a product receives its copied runtime assets, shared by install and repair.
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal static class SelfTests
|
||||
{
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal static class ShellIntegration
|
||||
{
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Content identity of everything that decides what the locally produced executables contain.
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using WiiCompiled.Setup.Common;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
internal static class UninstallService
|
||||
{
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>WiiCompiled.Setup</AssemblyName>
|
||||
<RootNamespace>WiiCompiled.Setup</RootNamespace>
|
||||
<RootNamespace>WiiCompiled.Setup.Windows</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<Version>0.2.24</Version>
|
||||
<Authors>patchzy</Authors>
|
||||
@@ -15,6 +15,6 @@
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WiiCompiled.NodTool\WiiCompiled.NodTool.csproj" />
|
||||
<ProjectReference Include="..\WiiCompiled.Setup.Common\WiiCompiled.Setup.Common.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace WiiCompiled.Setup;
|
||||
namespace WiiCompiled.Setup.Windows;
|
||||
|
||||
|
||||
internal static class WorkspaceTimestamps
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace WiiCompiled.Setup;
|
||||
|
||||
/// <summary>Reads and atomically writes the small JSON state documents kept inside an installation.</summary>
|
||||
internal 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 failed launch.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Write<T>(string path, T value) =>
|
||||
FileSystemUtilities.WriteAtomic(path, JsonSerializer.Serialize(value, WriteOptions));
|
||||
}
|
||||
@@ -61,12 +61,12 @@ dotnet publish "$workspace/translator/src/Translator.Cli" -c Release -r linux-x6
|
||||
cp "$translator_publish_tmp/Translator.Cli" "$appdir/usr/bin/translator-cli"
|
||||
chmod +x "$appdir/usr/bin/translator-cli"
|
||||
|
||||
# Resolved via the shared WiiCompiled.NodTool.Cli helper (also used by Build-Installer.ps1 on
|
||||
# Resolved via the shared WiiCompiled.Setup.Common.Cli helper (also used by Build-Installer.ps1 on
|
||||
# Windows) rather than a second curl/version-pin copy here: it downloads and caches the same way
|
||||
# NodToolProvider.cs always does (Launcher/artifacts/nodtool), so there is exactly one place that
|
||||
# knows the nodtool version/URL/platform-asset mapping.
|
||||
echo "Resolving nodtool..."
|
||||
nodtool_path=$(dotnet run --project "$workspace/Launcher/WiiCompiled.NodTool.Cli" -c Release -- \
|
||||
nodtool_path=$(dotnet run --project "$workspace/Launcher/WiiCompiled.Setup.Common.Cli" -c Release -- \
|
||||
--workspace "$workspace" | tail -n1)
|
||||
cp "$nodtool_path" "$appdir/usr/bin/nodtool"
|
||||
chmod +x "$appdir/usr/bin/nodtool"
|
||||
|
||||
@@ -52,7 +52,6 @@ output_dir=""
|
||||
base_output_dir=""
|
||||
retro_rewind_package_dir=""
|
||||
retro_wfc_offline_dir=""
|
||||
retro_wfc_payload_origin=offline
|
||||
skip_retro_wfc_payload=0
|
||||
force_clean_build=0
|
||||
parallel_override=0
|
||||
@@ -93,7 +92,6 @@ while [[ $# -gt 0 ]]; do
|
||||
--base-output-dir) base_output_dir=$2; shift 2 ;;
|
||||
--retro-rewind-package-dir) retro_rewind_package_dir=$2; shift 2 ;;
|
||||
--retro-wfc-offline-dir) retro_wfc_offline_dir=$2; shift 2 ;;
|
||||
--retro-wfc-payload-origin) retro_wfc_payload_origin=$2; shift 2 ;;
|
||||
--skip-retro-wfc-payload) skip_retro_wfc_payload=1; shift ;;
|
||||
--force-clean-build) force_clean_build=1; shift ;;
|
||||
--parallel) parallel_override=$2; shift 2 ;;
|
||||
|
||||
Reference in New Issue
Block a user