feat(launcher): add verified first-run installer
This commit is contained in:
parent
60f627998c
commit
ff6ebb6a6a
28 changed files with 3259 additions and 125 deletions
466
src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs
Normal file
466
src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
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)
|
||||
{
|
||||
InstallRecordVerification verification = await _recordStore
|
||||
.LoadAndVerifyAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_verifiedRecord = verification.Record;
|
||||
return verification;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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);
|
||||
if (_verifiedRecord is null)
|
||||
{
|
||||
InstallRecordVerification existing = await _recordStore
|
||||
.LoadAndVerifyAsync(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();
|
||||
BakeStartedEvent? started = null;
|
||||
BakeCompletedEvent? completed = null;
|
||||
string? protocolError = null;
|
||||
string? childError = null;
|
||||
|
||||
void Observe(BakeProgressEvent progressEvent)
|
||||
{
|
||||
switch (progressEvent)
|
||||
{
|
||||
case BakeStartedEvent value:
|
||||
started = value;
|
||||
break;
|
||||
case BakeWorkProgressEvent value:
|
||||
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 BakeCompletedEvent value:
|
||||
completed = value;
|
||||
break;
|
||||
case BakeErrorEvent value:
|
||||
childError = value.Message;
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.Failed,
|
||||
$"Bake tool error: {value.Message}");
|
||||
break;
|
||||
case MalformedBakeProgressEvent value:
|
||||
protocolError ??= value.Reason;
|
||||
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();
|
||||
var request = new BakeProcessRequest(
|
||||
_bakeExecutablePath,
|
||||
validation.Directory,
|
||||
outputPath,
|
||||
threads);
|
||||
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);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (processResult.ExitCode != 0)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
BuildChildFailure(
|
||||
processResult.ExitCode,
|
||||
childError,
|
||||
processResult.StandardError));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(childError))
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
$"The bake tool reported an error: {childError}");
|
||||
}
|
||||
|
||||
if (protocolError is not null)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
$"The bake tool emitted malformed JSON progress: {protocolError}");
|
||||
}
|
||||
|
||||
if (started is null || completed is null)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
"The bake tool exited without the required v1 started/completed "
|
||||
+ "progress records.");
|
||||
}
|
||||
|
||||
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.SaveAtomicallyAsync(record, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_verifiedRecord = record;
|
||||
LauncherInstallRecordStore.TryDelete(backupPath);
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.Completed,
|
||||
"Client content installed and verified.",
|
||||
completed: 1,
|
||||
total: 1);
|
||||
return new LauncherInstallResult(record);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.Cancelled,
|
||||
"Installation cancelled; no new install record was published.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.Failed,
|
||||
$"Installation failed: {ex.Message}");
|
||||
if (ex is LauncherInstallException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
throw new LauncherInstallException("Installation failed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue