acdream/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs

561 lines
20 KiB
C#

using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Launching;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Installation;
public enum LauncherInstallPhase
{
Idle,
ValidatingDatFiles,
PreparingOutput,
BakingMeshes,
BakingCollision,
VerifyingPackage,
SavingRecord,
Completed,
Cancelled,
Failed,
}
public sealed record LauncherInstallProgress(
LauncherInstallPhase Phase,
string Status,
long Completed = 0,
long Total = 0,
int Failures = 0,
double EtaSeconds = 0)
{
public double Fraction => Total > 0
? Math.Clamp((double)Completed / Total, 0, 1)
: 0;
}
public sealed record LauncherInstallResult(LauncherInstallRecord Record);
public sealed class LauncherInstallException : Exception
{
public LauncherInstallException(string message)
: base(message)
{
}
public LauncherInstallException(string message, Exception innerException)
: base(message, innerException)
{
}
}
public interface ILauncherInstaller
{
IReadOnlyList<DatDirectoryValidation> DetectDatDirectories();
DatDirectoryValidation ValidateDatDirectory(string? directory);
Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default);
Task<LauncherInstallResult> InstallAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// BCL-only first-run transaction. It invokes the GL-free bake executable as
/// a child, consumes only its versioned JSONL records, verifies the published
/// pak, and atomically records the install. A prior verified package is moved
/// to an adjacent recovery slot and restored on every failure/cancellation
/// path, so a fake or crashed child cannot replace it with partial output.
/// </summary>
public sealed class LauncherInstaller : ILauncherInstaller
{
private readonly string _bakeExecutablePath;
private readonly DatDirectoryLocator _datDirectories;
private readonly LauncherInstallRecordStore _recordStore;
private readonly IBakeProcessRunner _processRunner;
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
private readonly SemaphoreSlim _installGate = new(1, 1);
private LauncherInstallRecord? _verifiedRecord;
public LauncherInstaller(
ApplicationPathSet paths,
string bakeExecutablePath,
DatDirectoryLocator? datDirectories = null,
LauncherInstallRecordStore? recordStore = null,
IBakeProcessRunner? processRunner = null,
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(bakeExecutablePath);
_bakeExecutablePath = Path.GetFullPath(bakeExecutablePath);
_datDirectories = datDirectories ?? new DatDirectoryLocator();
_computeSha256 = computeSha256
?? ((path, cancellationToken) =>
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
_recordStore = recordStore
?? new LauncherInstallRecordStore(
paths,
_datDirectories,
_computeSha256);
_processRunner = processRunner ?? new SystemBakeProcessRunner();
}
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() =>
_datDirectories.Detect();
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
_datDirectories.Validate(directory);
public async Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default)
{
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using InstallerTransactionLease lease =
await InstallerTransactionLease.AcquireAsync(
_recordStore.DataDirectory,
cancellationToken)
.ConfigureAwait(false);
InstallRecordVerification verification =
await RecoverExistingUnderPublicationGuardAsync(
cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = verification.Record;
return verification;
}
finally
{
_installGate.Release();
}
}
public async Task<LauncherInstallResult> InstallAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default)
{
if (threads <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(threads),
"Bake thread count must be positive.");
}
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using InstallerTransactionLease lease =
await InstallerTransactionLease.AcquireAsync(
_recordStore.DataDirectory,
cancellationToken)
.ConfigureAwait(false);
return await InstallCoreAsync(
datDirectory,
threads,
progress,
cancellationToken)
.ConfigureAwait(false);
}
finally
{
_installGate.Release();
}
}
private async Task<LauncherInstallResult> InstallCoreAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress,
CancellationToken cancellationToken)
{
Report(
progress,
LauncherInstallPhase.ValidatingDatFiles,
"Validating the four retail DAT files...");
DatDirectoryValidation validation = _datDirectories.Validate(datDirectory);
if (!validation.IsValid)
{
string message = validation.Message
+ FormatMissing(validation.MissingFileNames);
Report(progress, LauncherInstallPhase.Failed, message);
throw new LauncherInstallException(message);
}
if (!File.Exists(_bakeExecutablePath))
{
string message =
$"The co-deployed bake tool is missing at '{_bakeExecutablePath}'.";
Report(progress, LauncherInstallPhase.Failed, message);
throw new LauncherInstallException(message);
}
string outputPath = _recordStore.PreparedAssetPath;
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
InstallRecordVerification existing =
await RecoverExistingUnderPublicationGuardAsync(cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = existing.Record;
Directory.CreateDirectory(
Path.GetDirectoryName(outputPath)
?? throw new InvalidOperationException(
"The prepared package path has no parent directory."));
Report(
progress,
LauncherInstallPhase.PreparingOutput,
"Preparing the atomic package transaction...");
bool previousPreserved = PreservePreviousPackage(outputPath, backupPath);
if (!previousPreserved)
{
LauncherInstallRecordStore.TryDelete(backupPath);
}
var parser = new BakeProgressJsonlParser();
var protocol = new BakeProgressProtocol();
string? publicationNonce = null;
void Observe(BakeProgressEvent progressEvent)
{
bool accepted = protocol.Observe(progressEvent);
switch (progressEvent)
{
case BakeWorkProgressEvent value when accepted:
LauncherInstallPhase phase = value.Phase switch
{
"mesh" => LauncherInstallPhase.BakingMeshes,
"collision" => LauncherInstallPhase.BakingCollision,
_ => LauncherInstallPhase.BakingMeshes,
};
Report(
progress,
phase,
$"Baking {value.Phase} assets: "
+ $"{value.Completed:N0}/{value.Total:N0}; "
+ $"failures: {value.Failures:N0}",
value.Completed,
value.Total,
value.Failures,
value.EtaSeconds);
break;
case BakeErrorEvent value when accepted:
Report(
progress,
LauncherInstallPhase.Failed,
$"Bake tool error: {value.Message}");
break;
case MalformedBakeProgressEvent value:
Report(
progress,
LauncherInstallPhase.Failed,
$"Malformed bake progress: {value.Reason}");
break;
// Human lines are deliberately ignored, and unknown event
// kinds are forward-compatible. A future protocol version
// cannot satisfy the required v1 started/completed pair.
}
}
try
{
cancellationToken.ThrowIfCancellationRequested();
publicationNonce = BakePublicationGuardPaths.CreateNonce();
await using (
BakePublicationGuardContract.PublicationLease publication =
await BakePublicationGuardContract.AcquireAsync(
outputPath,
cancellationToken)
.ConfigureAwait(false))
{
BakePublicationGuardContract.Authorize(
outputPath,
publicationNonce,
publication);
}
var request = new BakeProcessRequest(
_bakeExecutablePath,
validation.Directory,
outputPath,
threads,
publicationNonce);
BakeProcessResult processResult = await _processRunner.RunAsync(
request,
chunk =>
{
foreach (BakeProgressEvent progressEvent in parser.Append(chunk))
{
Observe(progressEvent);
}
},
cancellationToken)
.ConfigureAwait(false);
foreach (BakeProgressEvent progressEvent in parser.Complete())
{
Observe(progressEvent);
}
protocol.CompleteInput();
cancellationToken.ThrowIfCancellationRequested();
if (protocol.Violation is not null)
{
throw new LauncherInstallException(protocol.Violation);
}
if (processResult.ExitCode != 0)
{
throw new LauncherInstallException(
BuildChildFailure(
processResult.ExitCode,
protocol.Error?.Message,
processResult.StandardError));
}
if (protocol.Error is not null)
{
throw new LauncherInstallException(
$"The bake tool reported an error: {protocol.Error.Message}");
}
BakeStartedEvent? started = protocol.Started;
BakeCompletedEvent? completed = protocol.Completed;
if (started is null || completed is null)
{
throw new LauncherInstallException(
"The bake protocol did not finish with a v1 completed event.");
}
if (started.BakeToolVersion != completed.BakeToolVersion
|| completed.BakeToolVersion
!= LauncherInstallRecordStore.CurrentBakeToolVersion)
{
throw new LauncherInstallException(
$"The bake tool reported version {completed.BakeToolVersion}; "
+ $"version {LauncherInstallRecordStore.CurrentBakeToolVersion} "
+ "is required.");
}
if (completed.Failures != 0)
{
throw new LauncherInstallException(
$"The bake completed with {completed.Failures:N0} failed assets.");
}
if (!File.Exists(outputPath))
{
throw new LauncherInstallException(
"The bake tool reported success but did not publish acdream.pak.");
}
long size = new FileInfo(outputPath).Length;
if (size <= 0 || size != completed.OutputBytes)
{
throw new LauncherInstallException(
"The published package size does not match the bake completion record.");
}
Report(
progress,
LauncherInstallPhase.VerifyingPackage,
"Computing the prepared package SHA-256...");
string sha256 = await _computeSha256(outputPath, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var record = new LauncherInstallRecord(
validation.Directory,
outputPath,
sha256,
size,
completed.BakeToolVersion);
Report(
progress,
LauncherInstallPhase.SavingRecord,
"Saving the verified install record...");
await _recordStore.SaveAtomicallyUnderLeaseAsync(
record,
cancellationToken)
.ConfigureAwait(false);
_verifiedRecord = record;
await FinalizeSuccessfulPublicationAsync(
outputPath,
backupPath,
publicationNonce)
.ConfigureAwait(false);
Report(
progress,
LauncherInstallPhase.Completed,
"Client content installed and verified.",
completed: 1,
total: 1);
return new LauncherInstallResult(record);
}
catch (OperationCanceledException)
{
await FinalizeFailedPublicationAsync(
outputPath,
backupPath,
previousPreserved,
publicationNonce)
.ConfigureAwait(false);
Report(
progress,
LauncherInstallPhase.Cancelled,
"Installation cancelled; no new install record was published.");
throw;
}
catch (Exception ex)
{
await FinalizeFailedPublicationAsync(
outputPath,
backupPath,
previousPreserved,
publicationNonce)
.ConfigureAwait(false);
Report(
progress,
LauncherInstallPhase.Failed,
$"Installation failed: {ex.Message}");
if (ex is LauncherInstallException)
{
throw;
}
throw new LauncherInstallException("Installation failed.", ex);
}
}
private async Task<InstallRecordVerification>
RecoverExistingUnderPublicationGuardAsync(
CancellationToken cancellationToken)
{
string outputPath = _recordStore.PreparedAssetPath;
await using BakePublicationGuardContract.PublicationLease publication =
await BakePublicationGuardContract.AcquireAsync(
outputPath,
cancellationToken)
.ConfigureAwait(false);
// Any child whose parent died before it acquired this lock is now
// irrevocably stale. A child already holding the lock must finish its
// promotion before recovery reaches this invalidation point.
BakePublicationGuardContract.Invalidate(outputPath, publication);
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken)
.ConfigureAwait(false);
}
private static async Task FinalizeSuccessfulPublicationAsync(
string outputPath,
string backupPath,
string publicationNonce)
{
await using BakePublicationGuardContract.PublicationLease publication =
await BakePublicationGuardContract.AcquireAsync(
outputPath,
CancellationToken.None)
.ConfigureAwait(false);
BakePublicationGuardContract.Invalidate(
outputPath,
publication,
publicationNonce);
LauncherInstallRecordStore.TryDelete(backupPath);
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
}
private static async Task FinalizeFailedPublicationAsync(
string outputPath,
string backupPath,
bool previousPreserved,
string? publicationNonce)
{
await using BakePublicationGuardContract.PublicationLease publication =
await BakePublicationGuardContract.AcquireAsync(
outputPath,
CancellationToken.None)
.ConfigureAwait(false);
BakePublicationGuardContract.Invalidate(
outputPath,
publication,
publicationNonce);
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
}
private bool PreservePreviousPackage(string outputPath, string backupPath)
{
LauncherInstallRecord? previous = _verifiedRecord;
if (previous is null
|| !PathsEqual(previous.PreparedAssetPath, outputPath)
|| !File.Exists(outputPath))
{
return false;
}
File.Move(outputPath, backupPath, overwrite: true);
return true;
}
private static void RestorePreviousPackage(
string outputPath,
string backupPath,
bool previousPreserved)
{
if (previousPreserved && File.Exists(backupPath))
{
File.Move(backupPath, outputPath, overwrite: true);
return;
}
LauncherInstallRecordStore.TryDelete(outputPath);
LauncherInstallRecordStore.TryDelete(backupPath);
}
private static string BuildChildFailure(
int exitCode,
string? jsonError,
string standardError)
{
string detail = !string.IsNullOrWhiteSpace(jsonError)
? jsonError
: standardError.Trim();
return detail.Length == 0
? $"The bake tool exited with code {exitCode}."
: $"The bake tool exited with code {exitCode}: {detail}";
}
private static void Report(
IProgress<LauncherInstallProgress>? progress,
LauncherInstallPhase phase,
string status,
long completed = 0,
long total = 0,
int failures = 0,
double etaSeconds = 0) =>
progress?.Report(new LauncherInstallProgress(
phase,
status,
completed,
total,
failures,
etaSeconds));
private static string FormatMissing(IReadOnlyList<string> missing) =>
missing.Count == 0
? string.Empty
: " Missing: " + string.Join(", ", missing) + ".";
private static bool PathsEqual(string left, string right) =>
string.Equals(
Path.GetFullPath(left),
Path.GetFullPath(right),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal);
}