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,329 @@
|
||||
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 }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user