561 lines
20 KiB
C#
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);
|
|
}
|