Files
wiicompiled/Launcher/WiiCompiled.Setup.Windows/ConsoleCommands.cs
T
theofficialgman c0ed2bfbeb 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).
2026-08-29 12:06:58 -04:00

330 lines
16 KiB
C#

using System.Text.Json;
using WiiCompiled.Setup.Common;
namespace WiiCompiled.Setup.Windows;
internal static class ConsoleCommands
{
public static int Help()
{
Console.Out.WriteLine($"{ProductInfo.Name} command-line setup {ProductInfo.Version}");
Console.Out.WriteLine("Wheel Wizard is the graphical interface for installing and launching WiiCompiled.");
Console.Out.WriteLine();
Console.Out.WriteLine("Commands:");
Console.Out.WriteLine(" --silent --game <image> --install-dir <dir> [--retro-dir <folder>] [--portable]");
Console.Out.WriteLine(" --verify-inputs --game <image> [--retro-dir <folder>]");
Console.Out.WriteLine(" --check-products [--install-dir <dir>] [--retro-dir <folder>] [--progress-json]");
Console.Out.WriteLine(" --repair-products --install-dir <dir> --retro-dir <folder> " +
"(--download-retro-wfc-payload | --skip-retro-wfc-payload) [--progress-json]");
Console.Out.WriteLine(" --launch-retro | --launch-base");
Console.Out.WriteLine(" --uninstall --install-dir <dir>");
Console.Out.WriteLine(" --version");
return 0;
}
public static int Version()
{
Console.Out.WriteLine(ProductInfo.Version);
return 0;
}
public static int EmitPayloadIdentities(CommandLine command)
{
var root = Path.GetFullPath(command.PayloadRootPath!);
if (!Directory.Exists(InstalledLayout.Toolkit(root)) ||
!Directory.Exists(InstalledLayout.Workspace(root)))
{
throw new InvalidDataException($"{root} is not a staged payload root: expected " +
$"{InstalledLayout.ToolkitDirectoryName} and {InstalledLayout.WorkspaceDirectoryName} " +
"directories.");
}
var components = ToolkitFingerprint.ComputeComponents(root);
var identities = new PayloadIdentities(
components.Compile,
components.Translation,
components.NativeToolchain,
ToolkitFingerprint.ComputePackage(root),
ToolkitFingerprint.ComputeRuntimeAssets(root));
Console.Out.WriteLine(JsonSerializer.Serialize(identities));
return 0;
}
private sealed record PayloadIdentities(string ToolkitFingerprint, string TranslationFingerprint,
string NativeToolchainFingerprint, string ToolkitPackageFingerprint,
string RuntimeAssetsFingerprint);
public static int VerifyInputs(CommandLine command)
{
var reporter = command.ProgressJson ? new NdjsonInstallReporter() : null;
var temp = Path.Combine(Path.GetTempPath(), "mkwc-verify-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(temp);
try
{
using var payload = PayloadArchive.OpenCurrent();
var manifest = payload.ReadManifest();
var tool = Path.Combine(temp, "nodtool.exe");
payload.ExtractEntry(InstalledLayout.ToolkitEntryPrefix + "nodtool.exe", tool);
payload.ExtractDirectory(InstalledLayout.ToolkitEntryPrefix + "Redist", temp);
reporter?.Progress(InstallStages.Validate, "Checking the Wii disc image...", 10);
var header = InputValidation.ReadDiscHeaderAsync(tool, command.GamePath!).GetAwaiter().GetResult();
InputValidation.EnsureCompatibleDisc(header, manifest);
if (command.RetroDirectoryPath is not null)
{
reporter?.Progress(InstallStages.Validate, "Checking the Retro Rewind folder...", 80);
_ = CompileInputsFingerprint.Compute(command.RetroDirectoryPath);
}
reporter?.Success(command.InstallDirectory is null
? ProductInfo.DefaultInstallDirectory
: Path.GetFullPath(command.InstallDirectory));
return 0;
}
catch (Exception ex) when (reporter is not null)
{
reporter.Failure(ex.Message);
Console.Error.WriteLine(ex);
return 1;
}
finally
{
reporter?.EnsureFinished("Input verification stopped before it reached a result.");
if (Directory.Exists(temp)) Directory.Delete(temp, recursive: true);
}
}
public static async Task<int> Install(CommandLine command, CancellationToken cancellationToken = default)
{
var logPath = Path.Combine(Path.GetTempPath(), "WiiCompiled-setup.log");
var logLock = new object();
void Log(string message)
{
lock (logLock)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] {message}{Environment.NewLine}");
}
}
File.WriteAllText(logPath, $"{ProductInfo.Name} setup {ProductInfo.Version}{Environment.NewLine}");
var ndjson = command.ProgressJson ? new NdjsonInstallReporter(Log) : null;
IInstallReporter reporter = ndjson ?? (IInstallReporter)new ConsoleInstallReporter(Log);
var installDirectory = Path.GetFullPath(command.InstallDirectory!);
try
{
var engine = new InstallerEngine(reporter);
await engine.InstallAsync(new InstallOptions
{
GamePath = command.GamePath!,
RetroDirectoryPath = command.RetroDirectoryPath,
RetroWfcPayloadMode = command.RetroWfcPayloadMode,
InstallDirectory = installDirectory,
Portable = command.Portable
}, cancellationToken);
ndjson?.Success(installDirectory);
return 0;
}
catch (Exception ex) when (ndjson is not null)
{
// The caller reads stdout as a protocol, so the failure has to arrive as the terminal
// result line; the exception detail belongs on stderr and in the setup log.
ndjson.Failure(ex.Message);
Console.Error.WriteLine(ex);
return 1;
}
finally
{
// Nothing - not a cancellation, not a process-level abort path - may leave the caller
// without the one line it waits for.
ndjson?.EnsureFinished("The installer stopped before it reached a result.");
}
}
/// <summary>Reports whether the installed products are current, without building, mutating, networking,
/// or walking the Retro Rewind asset tree. Exit code 2 means work is required, not a protocol failure.</summary>
public static int CheckProducts(CommandLine command,
CancellationToken cancellationToken = default)
{
var reporter = command.ProgressJson ? new NdjsonInstallReporter() : null;
var installation = new Installation(string.IsNullOrWhiteSpace(command.InstallDirectory)
? AppContext.BaseDirectory
: Path.GetFullPath(command.InstallDirectory));
try
{
// The lock also performs exact journal/scratch recovery. Acquire it for absent and
// partial roots too: a killed first install can have recoverable state even when the
// product executable/state file has not reached the live directory yet.
using var operationLock = InstallOperationLock.Acquire(installation.Root, reporter);
PortableInstallHealing.HealMovedInstall(installation, reporter);
var report = InspectProducts(installation, command.RetroDirectoryPath,
cancellationToken: cancellationToken);
Console.Out.WriteLine(command.ProgressJson
? SerializeProductsReport(report)
: $"base: {report.Base.Reason} {report.Base.Detail}".TrimEnd());
if (!command.ProgressJson)
Console.Out.WriteLine(
$"retro-rewind: {report.RetroRewind.Reason} {report.RetroRewind.Detail}".TrimEnd());
Console.Out.Flush();
reporter?.Success(installation.Root);
return report.RebuildRequired ? 2 : 0;
}
catch (Exception ex) when (reporter is not null)
{
reporter.Failure(ex.Message);
Console.Error.WriteLine(ex);
return 1;
}
finally
{
reporter?.EnsureFinished("The product check stopped before it reached a result.");
}
}
/// <summary>
/// Reconciles only the product work identified by a preceding health check. Every invocation
/// carries the canonical Retro Rewind folder and one payload option; the final inspection is
/// authoritative, so success is emitted only when every required product is current.
/// </summary>
public static async Task<int> RepairProducts(CommandLine command,
CancellationToken cancellationToken = default)
{
var logPath = Path.Combine(Path.GetTempPath(), "WiiCompiled-repair.log");
var logLock = new object();
void Log(string message)
{
lock (logLock)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] {message}{Environment.NewLine}");
}
}
var ndjson = command.ProgressJson ? new NdjsonInstallReporter(Log) : null;
IInstallReporter reporter = ndjson is null ? new ConsoleInstallReporter(Log) : ndjson;
var installDirectory = Path.GetFullPath(command.InstallDirectory!);
var installation = new Installation(installDirectory);
try
{
if (!installation.IsPresent)
throw new InvalidOperationException(
$"WiiCompiled is not installed at {installDirectory}.");
using var operationLock = InstallOperationLock.Acquire(installDirectory, reporter);
PortableInstallHealing.HealMovedInstall(installation, reporter);
cancellationToken.ThrowIfCancellationRequested();
var service = new ProductRepairService(installation, reporter);
using var scratch = InstallScratchSpace.CreateInsideInstall(installDirectory, reporter);
// Only compile inputs are copied out of the canonical installation, under the frontend's
// source lease. Its assets stay where they are and are read live at launch.
var canonicalRoot = RetroRewindSource.ResolveRetroRewind6(command.RetroDirectoryPath!);
var snapshot = CompileInputsFingerprint.Snapshot(canonicalRoot,
Path.Combine(scratch.Root, "compile-inputs"), cancellationToken);
var options = new ProductRepairService.ReconcileOptions
{
ScratchRoot = scratch.Root,
CanonicalRetroRewindRoot = canonicalRoot
};
var reconciliation = await service.RepairRetroAsync(snapshot, command.RetroWfcPayloadMode,
options, cancellationToken);
// ReconcileAsync is the completion barrier. Cancellation is observed throughout
// preparation and immediately before publication; a request racing with or following
// its uncancellable Publish/Commit must not turn committed state into a failure result.
// Reconciliation returns the exact payload-cache observation it proved or published, so
// terminal health does not hash the same payload a second time.
var after = InspectProducts(installation, snapshot,
reconciliation.CachedRetroWfcPayloadMatches);
if (after.RebuildRequired)
throw new InvalidOperationException(
"Product repair completed without producing a current installation: " +
after.ActionRequiredDetail);
ndjson?.Success(installDirectory);
return 0;
}
catch (Exception ex) when (ndjson is not null)
{
ndjson.Failure(ex is OperationCanceledException ? "Operation canceled." : ex.Message);
Console.Error.WriteLine(ex);
return 1;
}
finally
{
ndjson?.EnsureFinished("Product repair stopped before it reached a result.");
}
}
/// <summary>
/// The <c>products</c> record exactly as the v1 contract defines it: a typed status and a
/// human-readable detail per product, plus one aggregate. Nothing else is derived, because
/// nothing else is reported.
/// </summary>
internal sealed record ProductsReport(string InstallDirectory,
ProductState Base, ProductState RetroRewind)
{
public bool RebuildRequired => Base.ActionRequired || RetroRewind.ActionRequired;
public string ActionRequiredDetail => string.Join(" ",
new[] { Base, RetroRewind }
.Where(state => state.ActionRequired)
.Select(state => state.Detail)
.Where(detail => !string.IsNullOrWhiteSpace(detail)));
}
/// <summary>
/// Inspects both products against the canonical Retro Rewind installation: the caller's explicit
/// folder when it supplied one, otherwise the recorded <c>retro_rewind_root</c>.
/// </summary>
internal static ProductsReport InspectProducts(Installation installation,
string? canonicalRetroDirectory = null, bool? cachedRetroWfcPayloadMatches = null,
CancellationToken cancellationToken = default)
{
if (!installation.IsPresent) return AbsentReport(installation);
var canonical = installation.ResolveCanonicalCompileInputs(canonicalRetroDirectory,
out var canonicalError, cancellationToken);
return BuildReport(installation, canonical, canonicalError, cachedRetroWfcPayloadMatches);
}
/// <summary>
/// Builds the report from a compile-input identity the operation already observed under its
/// lock, so a repair does not hash the same canonical inputs at every decision boundary.
/// </summary>
internal static ProductsReport InspectProducts(Installation installation,
RetroRewindCompileInputs canonical, bool? cachedRetroWfcPayloadMatches = null) =>
installation.IsPresent
? BuildReport(installation, canonical, null, cachedRetroWfcPayloadMatches)
: AbsentReport(installation);
private static ProductsReport AbsentReport(Installation installation) =>
// Probing a path nothing was installed to is a valid answer. It must not create a directory
// or turn the frontend's first-install probe into a repair request.
new(installation.Root,
new ProductState(ProductStatus.Absent, "WiiCompiled is not installed here."),
new ProductState(ProductStatus.Absent, "Retro Rewind is not installed."));
private static ProductsReport BuildReport(Installation installation,
RetroRewindCompileInputs? canonical, string? canonicalError,
bool? cachedRetroWfcPayloadMatches)
{
var toolkitFingerprint = installation.ResolveToolkitFingerprint();
return new ProductsReport(installation.Root,
installation.CheckBase(toolkitFingerprint),
installation.CheckRetroRewind(toolkitFingerprint, canonical, canonicalError,
cachedRetroWfcPayloadMatches));
}
/// <summary>
/// Emits the stable <c>products</c> record. The frontend branches on <c>status</c> only, never
/// on the detail text, and an unrecognized status fails closed on its side.
/// </summary>
internal static string SerializeProductsReport(ProductsReport report) =>
System.Text.Json.JsonSerializer.Serialize(new
{
type = "products",
setupVersion = ProductInfo.Version,
installDir = report.InstallDirectory,
rebuildRequired = report.RebuildRequired,
@base = new { status = report.Base.Reason, detail = report.Base.Detail },
retroRewind = new { status = report.RetroRewind.Reason, detail = report.RetroRewind.Detail }
});
}