merge: Campaign LA LA9 - verified installer review-closed
This commit is contained in:
commit
2198a0cc8e
45 changed files with 5143 additions and 132 deletions
|
|
@ -14,6 +14,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests" />
|
||||
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>
|
||||
/// Exact adjacent temporary-file contract emitted by AcDream.Bake's
|
||||
/// BakeOutputTransaction. This class intentionally has no Bake project
|
||||
/// dependency: both sides pin the same documented format with conformance
|
||||
/// tests so Launcher.Core remains BCL-only.
|
||||
/// </summary>
|
||||
internal static class BakeOutputStagingContract
|
||||
{
|
||||
internal const string StagingMarker = ".acdream-bake.";
|
||||
private const string Suffix = ".tmp";
|
||||
|
||||
internal static string CreateStagingPath(
|
||||
string destinationPath,
|
||||
Guid transactionId)
|
||||
{
|
||||
string fullDestination = Path.GetFullPath(destinationPath);
|
||||
string directory = Path.GetDirectoryName(fullDestination)
|
||||
?? throw new InvalidOperationException(
|
||||
"The prepared package path has no parent directory.");
|
||||
return Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(fullDestination)}{StagingMarker}"
|
||||
+ $"{transactionId:N}{Suffix}");
|
||||
}
|
||||
|
||||
internal static bool IsOwnedStagingFileName(
|
||||
string fileName,
|
||||
string destinationFileName)
|
||||
{
|
||||
string prefix = $".{destinationFileName}{StagingMarker}";
|
||||
if (!fileName.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| !fileName.EndsWith(Suffix, StringComparison.Ordinal)
|
||||
|| fileName.Length != prefix.Length + 32 + Suffix.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ReadOnlySpan<char> transaction = fileName.AsSpan(prefix.Length, 32);
|
||||
return Guid.TryParseExact(transaction, "N", out _);
|
||||
}
|
||||
|
||||
internal static void DeleteOwnedStagingFiles(string destinationPath)
|
||||
{
|
||||
string fullDestination = Path.GetFullPath(destinationPath);
|
||||
string? directory = Path.GetDirectoryName(fullDestination);
|
||||
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string destinationFileName = Path.GetFileName(fullDestination);
|
||||
try
|
||||
{
|
||||
foreach (string candidate in Directory.EnumerateFiles(directory))
|
||||
{
|
||||
if (IsOwnedStagingFileName(
|
||||
Path.GetFileName(candidate),
|
||||
destinationFileName))
|
||||
{
|
||||
LauncherInstallRecordStore.TryDelete(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Best effort: these files are never launchable. A later startup
|
||||
// retries exact-name cleanup under the publication lock.
|
||||
}
|
||||
}
|
||||
}
|
||||
198
src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs
Normal file
198
src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>The exact child-process contract for one launcher bake.</summary>
|
||||
public sealed record BakeProcessRequest(
|
||||
string ExecutablePath,
|
||||
string DatDirectory,
|
||||
string OutputPath,
|
||||
int Threads,
|
||||
string? PublicationNonce = null)
|
||||
{
|
||||
public IReadOnlyList<string> Arguments =>
|
||||
[
|
||||
"--dat-dir",
|
||||
DatDirectory,
|
||||
"--out",
|
||||
OutputPath,
|
||||
"--threads",
|
||||
Threads.ToString(CultureInfo.InvariantCulture),
|
||||
"--progress-json",
|
||||
];
|
||||
}
|
||||
|
||||
public sealed record BakeProcessResult(int ExitCode, string StandardError);
|
||||
|
||||
/// <summary>
|
||||
/// Injectable child seam. Stdout is delivered as arbitrary chunks so the
|
||||
/// versioned JSONL parser, rather than line-oriented process plumbing, owns
|
||||
/// partial-record behavior.
|
||||
/// </summary>
|
||||
public interface IBakeProcessRunner
|
||||
{
|
||||
Task<BakeProcessResult> RunAsync(
|
||||
BakeProcessRequest request,
|
||||
Action<string> onStandardOutput,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class SystemBakeProcessRunner : IBakeProcessRunner
|
||||
{
|
||||
private const int BufferSize = 4096;
|
||||
private const int MaximumCapturedErrorCharacters = 32 * 1024;
|
||||
|
||||
public async Task<BakeProcessResult> RunAsync(
|
||||
BakeProcessRequest request,
|
||||
Action<string> onStandardOutput,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(onStandardOutput);
|
||||
if (request.Threads <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(request),
|
||||
"Bake thread count must be positive.");
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
ProcessStartInfo startInfo = CreateStartInfo(request);
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("The bake process could not be started.");
|
||||
}
|
||||
|
||||
// The bake consumes no credential or other stdin input.
|
||||
process.StandardInput.Close();
|
||||
|
||||
var standardError = new StringBuilder();
|
||||
Task stdoutPump = PumpAsync(
|
||||
process.StandardOutput,
|
||||
onStandardOutput,
|
||||
CancellationToken.None);
|
||||
Task stderrPump = PumpAsync(
|
||||
process.StandardError,
|
||||
chunk => AppendBounded(standardError, chunk),
|
||||
CancellationToken.None);
|
||||
|
||||
using CancellationTokenRegistration cancellation = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
var child = (Process)state!;
|
||||
try
|
||||
{
|
||||
if (!child.HasExited)
|
||||
{
|
||||
child.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The cancellation token remains authoritative. Races with
|
||||
// natural exit or handle teardown do not replace it.
|
||||
}
|
||||
},
|
||||
process);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
await Task.WhenAll(stdoutPump, stderrPump).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new BakeProcessResult(process.ExitCode, standardError.ToString());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cleanupTimeout = new CancellationTokenSource(
|
||||
TimeSpan.FromSeconds(5));
|
||||
await process.WaitForExitAsync(cleanupTimeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
await Task.WhenAll(stdoutPump, stderrPump)
|
||||
.WaitAsync(cleanupTimeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Preserve cancellation. The process kill registration above
|
||||
// already made the best effort to terminate the tree.
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal static ProcessStartInfo CreateStartInfo(BakeProcessRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = request.ExecutablePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (string argument in request.Arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
startInfo.Environment.Remove(
|
||||
BakePublicationGuardPaths.NonceEnvironmentVariable);
|
||||
if (request.PublicationNonce is not null)
|
||||
{
|
||||
if (!BakePublicationGuardPaths.IsValidNonce(
|
||||
request.PublicationNonce))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The bake publication nonce is invalid.",
|
||||
nameof(request));
|
||||
}
|
||||
|
||||
startInfo.Environment[
|
||||
BakePublicationGuardPaths.NonceEnvironmentVariable] =
|
||||
request.PublicationNonce;
|
||||
}
|
||||
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(
|
||||
TextReader reader,
|
||||
Action<string> sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
char[] buffer = new char[BufferSize];
|
||||
while (true)
|
||||
{
|
||||
int read = await reader.ReadAsync(buffer, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sink(new string(buffer, 0, read));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendBounded(StringBuilder destination, string chunk)
|
||||
{
|
||||
int remaining = MaximumCapturedErrorCharacters - destination.Length;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
destination.Append(chunk.AsSpan(0, Math.Min(remaining, chunk.Length)));
|
||||
}
|
||||
}
|
||||
54
src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs
Normal file
54
src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>
|
||||
/// Versioned machine-readable output from <c>acdream-bake
|
||||
/// --progress-json</c>. Human output shares stdout but remains a distinct
|
||||
/// event so the installer never derives state by scraping prose.
|
||||
/// </summary>
|
||||
public abstract record BakeProgressEvent(int Version, string EventName);
|
||||
|
||||
public sealed record BakeStartedEvent(
|
||||
int Version,
|
||||
uint BakeToolVersion,
|
||||
string? OutputPath)
|
||||
: BakeProgressEvent(Version, "started");
|
||||
|
||||
public sealed record BakeWorkProgressEvent(
|
||||
int Version,
|
||||
string Phase,
|
||||
long Completed,
|
||||
long Total,
|
||||
int Failures,
|
||||
double ElapsedSeconds,
|
||||
double EtaSeconds)
|
||||
: BakeProgressEvent(Version, "progress");
|
||||
|
||||
public sealed record BakeCompletedEvent(
|
||||
int Version,
|
||||
uint BakeToolVersion,
|
||||
long OutputBytes,
|
||||
int Failures)
|
||||
: BakeProgressEvent(Version, "completed");
|
||||
|
||||
public sealed record BakeErrorEvent(int Version, string Message)
|
||||
: BakeProgressEvent(Version, "error");
|
||||
|
||||
public sealed record UnknownBakeProgressEvent(
|
||||
int Version,
|
||||
string EventName,
|
||||
string RawLine)
|
||||
: BakeProgressEvent(Version, EventName);
|
||||
|
||||
public sealed record FutureBakeProgressEvent(
|
||||
int Version,
|
||||
string EventName,
|
||||
string RawLine)
|
||||
: BakeProgressEvent(Version, EventName);
|
||||
|
||||
public sealed record MalformedBakeProgressEvent(
|
||||
string RawLine,
|
||||
string Reason)
|
||||
: BakeProgressEvent(0, "malformed");
|
||||
|
||||
public sealed record BakeHumanOutputEvent(string Text)
|
||||
: BakeProgressEvent(0, "human");
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>
|
||||
/// Incremental JSONL parser tolerant of arbitrary stream chunk boundaries.
|
||||
/// Unknown event names and future protocol versions stay observable without
|
||||
/// failing the bake; malformed known payloads are explicit typed events.
|
||||
/// </summary>
|
||||
public sealed class BakeProgressJsonlParser
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
private readonly StringBuilder _pending = new();
|
||||
|
||||
public IReadOnlyList<BakeProgressEvent> Append(string chunk)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chunk);
|
||||
_pending.Append(chunk);
|
||||
return Drain(completeFinalLine: false);
|
||||
}
|
||||
|
||||
public IReadOnlyList<BakeProgressEvent> Complete() =>
|
||||
Drain(completeFinalLine: true);
|
||||
|
||||
public static BakeProgressEvent ParseLine(string line)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(line);
|
||||
string trimmed = line.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return new BakeHumanOutputEvent(string.Empty);
|
||||
}
|
||||
|
||||
if (trimmed[0] != '{')
|
||||
{
|
||||
return new BakeHumanOutputEvent(line.TrimEnd('\r'));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(trimmed);
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !TryGetInt32(root, "v", out int version)
|
||||
|| !TryGetString(root, "e", out string? eventName))
|
||||
{
|
||||
return Malformed(line, "JSON progress requires integer 'v' and string 'e'.");
|
||||
}
|
||||
|
||||
if (version != CurrentVersion)
|
||||
{
|
||||
return new FutureBakeProgressEvent(version, eventName!, line);
|
||||
}
|
||||
|
||||
return eventName switch
|
||||
{
|
||||
"started" => ParseStarted(root, version, line),
|
||||
"progress" => ParseProgress(root, version, line),
|
||||
"completed" => ParseCompleted(root, version, line),
|
||||
"error" => ParseError(root, version, line),
|
||||
_ => new UnknownBakeProgressEvent(version, eventName!, line),
|
||||
};
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return Malformed(line, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<BakeProgressEvent> Drain(bool completeFinalLine)
|
||||
{
|
||||
var events = new List<BakeProgressEvent>();
|
||||
while (true)
|
||||
{
|
||||
int newline = IndexOfNewline(_pending);
|
||||
if (newline < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
string line = _pending.ToString(0, newline);
|
||||
_pending.Remove(0, newline + 1);
|
||||
events.Add(ParseLine(line));
|
||||
}
|
||||
|
||||
if (completeFinalLine && _pending.Length > 0)
|
||||
{
|
||||
string line = _pending.ToString();
|
||||
_pending.Clear();
|
||||
events.Add(ParseLine(line));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static int IndexOfNewline(StringBuilder value)
|
||||
{
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (value[i] == '\n')
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static BakeProgressEvent ParseStarted(
|
||||
JsonElement root,
|
||||
int version,
|
||||
string raw)
|
||||
{
|
||||
if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion)
|
||||
|| bakeToolVersion == 0)
|
||||
{
|
||||
return Malformed(raw, "started requires a positive bakeToolVersion.");
|
||||
}
|
||||
|
||||
_ = TryGetString(root, "outputPath", out string? outputPath);
|
||||
return new BakeStartedEvent(version, bakeToolVersion, outputPath);
|
||||
}
|
||||
|
||||
private static BakeProgressEvent ParseProgress(
|
||||
JsonElement root,
|
||||
int version,
|
||||
string raw)
|
||||
{
|
||||
if (!TryGetString(root, "phase", out string? phase)
|
||||
|| !TryGetInt64(root, "completed", out long completed)
|
||||
|| !TryGetInt64(root, "total", out long total)
|
||||
|| !TryGetInt32(root, "failures", out int failures)
|
||||
|| !TryGetDouble(root, "elapsedSeconds", out double elapsedSeconds)
|
||||
|| !TryGetDouble(root, "etaSeconds", out double etaSeconds)
|
||||
|| completed < 0
|
||||
|| total < 0
|
||||
|| completed > total
|
||||
|| failures < 0
|
||||
|| elapsedSeconds < 0
|
||||
|| etaSeconds < 0)
|
||||
{
|
||||
return Malformed(raw, "progress payload has missing or invalid fields.");
|
||||
}
|
||||
|
||||
return new BakeWorkProgressEvent(
|
||||
version,
|
||||
phase!,
|
||||
completed,
|
||||
total,
|
||||
failures,
|
||||
elapsedSeconds,
|
||||
etaSeconds);
|
||||
}
|
||||
|
||||
private static BakeProgressEvent ParseCompleted(
|
||||
JsonElement root,
|
||||
int version,
|
||||
string raw)
|
||||
{
|
||||
if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion)
|
||||
|| !TryGetInt64(root, "outputBytes", out long outputBytes)
|
||||
|| !TryGetInt32(root, "failures", out int failures)
|
||||
|| bakeToolVersion == 0
|
||||
|| outputBytes <= 0
|
||||
|| failures < 0)
|
||||
{
|
||||
return Malformed(raw, "completed payload has missing or invalid fields.");
|
||||
}
|
||||
|
||||
return new BakeCompletedEvent(
|
||||
version,
|
||||
bakeToolVersion,
|
||||
outputBytes,
|
||||
failures);
|
||||
}
|
||||
|
||||
private static BakeProgressEvent ParseError(
|
||||
JsonElement root,
|
||||
int version,
|
||||
string raw) =>
|
||||
TryGetString(root, "message", out string? message)
|
||||
&& !string.IsNullOrWhiteSpace(message)
|
||||
? new BakeErrorEvent(version, message)
|
||||
: Malformed(raw, "error requires a non-empty message.");
|
||||
|
||||
private static MalformedBakeProgressEvent Malformed(string raw, string reason) =>
|
||||
new(raw, reason);
|
||||
|
||||
private static bool TryGetString(
|
||||
JsonElement root,
|
||||
string name,
|
||||
out string? value)
|
||||
{
|
||||
value = null;
|
||||
return root.TryGetProperty(name, out JsonElement element)
|
||||
&& element.ValueKind == JsonValueKind.String
|
||||
&& (value = element.GetString()) is not null;
|
||||
}
|
||||
|
||||
private static bool TryGetInt32(JsonElement root, string name, out int value)
|
||||
{
|
||||
value = default;
|
||||
return root.TryGetProperty(name, out JsonElement element)
|
||||
&& element.TryGetInt32(out value);
|
||||
}
|
||||
|
||||
private static bool TryGetUInt32(JsonElement root, string name, out uint value)
|
||||
{
|
||||
value = default;
|
||||
return root.TryGetProperty(name, out JsonElement element)
|
||||
&& element.TryGetUInt32(out value);
|
||||
}
|
||||
|
||||
private static bool TryGetInt64(JsonElement root, string name, out long value)
|
||||
{
|
||||
value = default;
|
||||
return root.TryGetProperty(name, out JsonElement element)
|
||||
&& element.TryGetInt64(out value);
|
||||
}
|
||||
|
||||
private static bool TryGetDouble(JsonElement root, string name, out double value)
|
||||
{
|
||||
value = default;
|
||||
return root.TryGetProperty(name, out JsonElement element)
|
||||
&& element.TryGetDouble(out value)
|
||||
&& double.IsFinite(value);
|
||||
}
|
||||
}
|
||||
121
src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs
Normal file
121
src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>
|
||||
/// Strict state machine for known v1 bake events. Human output, unknown v1
|
||||
/// events, and future versions are deliberately transparent; known v1 events
|
||||
/// cannot be reordered, duplicated, or appended after the first terminal.
|
||||
/// </summary>
|
||||
internal sealed class BakeProgressProtocol
|
||||
{
|
||||
private BakeProgressProtocolState _state;
|
||||
|
||||
internal BakeStartedEvent? Started { get; private set; }
|
||||
|
||||
internal BakeCompletedEvent? Completed { get; private set; }
|
||||
|
||||
internal BakeErrorEvent? Error { get; private set; }
|
||||
|
||||
internal string? Violation { get; private set; }
|
||||
|
||||
internal bool Observe(BakeProgressEvent progressEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(progressEvent);
|
||||
if (Violation is not null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (progressEvent)
|
||||
{
|
||||
case BakeHumanOutputEvent:
|
||||
case UnknownBakeProgressEvent:
|
||||
case FutureBakeProgressEvent:
|
||||
return true;
|
||||
case MalformedBakeProgressEvent malformed:
|
||||
Reject($"Malformed bake progress: {malformed.Reason}");
|
||||
return false;
|
||||
case BakeStartedEvent started:
|
||||
if (_state != BakeProgressProtocolState.AwaitingStarted)
|
||||
{
|
||||
Reject(_state == BakeProgressProtocolState.Running
|
||||
? "The bake protocol emitted more than one v1 started event."
|
||||
: "The bake protocol emitted a known event after its terminal event.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Started = started;
|
||||
_state = BakeProgressProtocolState.Running;
|
||||
return true;
|
||||
case BakeWorkProgressEvent:
|
||||
if (_state != BakeProgressProtocolState.Running)
|
||||
{
|
||||
Reject(KnownEventStateViolation("progress"));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
case BakeCompletedEvent completed:
|
||||
if (_state != BakeProgressProtocolState.Running)
|
||||
{
|
||||
Reject(KnownEventStateViolation("completed"));
|
||||
return false;
|
||||
}
|
||||
|
||||
Completed = completed;
|
||||
_state = BakeProgressProtocolState.Completed;
|
||||
return true;
|
||||
case BakeErrorEvent error:
|
||||
if (_state != BakeProgressProtocolState.Running)
|
||||
{
|
||||
Reject(KnownEventStateViolation("error"));
|
||||
return false;
|
||||
}
|
||||
|
||||
Error = error;
|
||||
_state = BakeProgressProtocolState.Error;
|
||||
return true;
|
||||
default:
|
||||
Reject("The bake protocol emitted an unsupported known event.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal void CompleteInput()
|
||||
{
|
||||
if (Violation is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_state == BakeProgressProtocolState.AwaitingStarted)
|
||||
{
|
||||
Reject("The bake protocol did not emit a v1 started event first.");
|
||||
}
|
||||
else if (_state == BakeProgressProtocolState.Running)
|
||||
{
|
||||
Reject("The bake protocol ended without exactly one terminal event.");
|
||||
}
|
||||
}
|
||||
|
||||
private string KnownEventStateViolation(string eventName) => _state switch
|
||||
{
|
||||
BakeProgressProtocolState.AwaitingStarted =>
|
||||
$"The bake protocol emitted v1 {eventName} before v1 started.",
|
||||
BakeProgressProtocolState.Running =>
|
||||
$"The bake protocol emitted an invalid v1 {eventName} event.",
|
||||
_ => "The bake protocol emitted a known event after its terminal event.",
|
||||
};
|
||||
|
||||
private void Reject(string message)
|
||||
{
|
||||
Violation ??= message;
|
||||
}
|
||||
|
||||
private enum BakeProgressProtocolState
|
||||
{
|
||||
AwaitingStarted,
|
||||
Running,
|
||||
Completed,
|
||||
Error,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>
|
||||
/// Launcher half of the environment-only Bake publication guard. Paths are
|
||||
/// derived from the canonical output path, while a durable GUID nonce grants
|
||||
/// one child permission to promote its already-validated adjacent staging
|
||||
/// file. Every token mutation happens while the stable publication lock is
|
||||
/// held.
|
||||
/// </summary>
|
||||
internal static class BakePublicationGuardContract
|
||||
{
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
internal static async ValueTask<PublicationLease> AcquireAsync(
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string lockPath = BakePublicationGuardPaths.GetPublishLockPath(
|
||||
outputPath);
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(lockPath)
|
||||
?? throw new InvalidOperationException(
|
||||
"The bake publication lock has no parent directory."));
|
||||
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
return new PublicationLease(new FileStream(
|
||||
lockPath,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None,
|
||||
bufferSize: 1,
|
||||
options: FileOptions.None));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
await Task.Delay(RetryDelay, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void Authorize(
|
||||
string outputPath,
|
||||
string nonce,
|
||||
PublicationLease lease)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lease);
|
||||
if (!BakePublicationGuardPaths.IsValidNonce(nonce))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The bake publication nonce must be a lowercase GUID in N format.",
|
||||
nameof(nonce));
|
||||
}
|
||||
|
||||
string authorizationPath =
|
||||
BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
|
||||
using var stream = new FileStream(
|
||||
authorizationPath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 4096,
|
||||
options: FileOptions.WriteThrough);
|
||||
using var writer = new StreamWriter(stream, leaveOpen: true);
|
||||
writer.Write(nonce);
|
||||
writer.Flush();
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
internal static void Invalidate(
|
||||
string outputPath,
|
||||
PublicationLease lease,
|
||||
string? onlyIfNonceMatches = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lease);
|
||||
string authorizationPath =
|
||||
BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
|
||||
if (!File.Exists(authorizationPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (onlyIfNonceMatches is not null)
|
||||
{
|
||||
string current = File.ReadAllText(authorizationPath);
|
||||
|
||||
if (!string.Equals(
|
||||
current,
|
||||
onlyIfNonceMatches,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
File.Delete(authorizationPath);
|
||||
}
|
||||
|
||||
internal sealed class PublicationLease : IAsyncDisposable
|
||||
{
|
||||
private readonly FileStream _stream;
|
||||
|
||||
internal PublicationLease(FileStream stream)
|
||||
{
|
||||
_stream = stream;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_stream.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs
Normal file
138
src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>
|
||||
/// Portable validation and Windows-only discovery for the four retail data
|
||||
/// archives consumed by <c>acdream-bake</c>. Discovery is intentionally only
|
||||
/// a list of conventional paths; validation is the same filesystem operation
|
||||
/// on Windows and Linux, including for a manually entered path.
|
||||
/// </summary>
|
||||
public sealed class DatDirectoryLocator
|
||||
{
|
||||
public static IReadOnlyList<string> RequiredFileNames { get; } =
|
||||
Array.AsReadOnly<string>(
|
||||
[
|
||||
"client_portal.dat",
|
||||
"client_cell_1.dat",
|
||||
"client_highres.dat",
|
||||
"client_local_English.dat",
|
||||
]);
|
||||
|
||||
private readonly bool _isWindows;
|
||||
private readonly string[] _windowsCandidates;
|
||||
private readonly Func<string, bool> _directoryExists;
|
||||
private readonly Func<string, bool> _fileExists;
|
||||
|
||||
public DatDirectoryLocator(
|
||||
bool? isWindows = null,
|
||||
IEnumerable<string>? windowsCandidates = null,
|
||||
Func<string, bool>? directoryExists = null,
|
||||
Func<string, bool>? fileExists = null)
|
||||
{
|
||||
_isWindows = isWindows ?? OperatingSystem.IsWindows();
|
||||
_windowsCandidates = (windowsCandidates ?? DefaultWindowsCandidates())
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Select(Path.GetFullPath)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
_directoryExists = directoryExists ?? Directory.Exists;
|
||||
_fileExists = fileExists ?? File.Exists;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns conventional Windows locations that actually exist, in
|
||||
/// preference order. An existing but incomplete directory remains in the
|
||||
/// result so the wizard can explain exactly which DATs are missing.
|
||||
/// Linux returns an empty list and relies on the manual picker/path field.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DatDirectoryValidation> Detect()
|
||||
{
|
||||
if (!_isWindows)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return _windowsCandidates
|
||||
.Where(_directoryExists)
|
||||
.Select(Validate)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public DatDirectoryValidation Validate(string? directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return DatDirectoryValidation.Invalid(
|
||||
directory ?? string.Empty,
|
||||
"Choose the folder containing the retail DAT files.",
|
||||
RequiredFileNames);
|
||||
}
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException
|
||||
or NotSupportedException
|
||||
or PathTooLongException)
|
||||
{
|
||||
return DatDirectoryValidation.Invalid(
|
||||
directory,
|
||||
"The DAT directory path is not valid.",
|
||||
RequiredFileNames);
|
||||
}
|
||||
|
||||
if (!_directoryExists(fullPath))
|
||||
{
|
||||
return DatDirectoryValidation.Invalid(
|
||||
fullPath,
|
||||
"The DAT directory does not exist.",
|
||||
RequiredFileNames);
|
||||
}
|
||||
|
||||
string[] missing = RequiredFileNames
|
||||
.Where(fileName => !_fileExists(Path.Combine(fullPath, fileName)))
|
||||
.ToArray();
|
||||
return missing.Length == 0
|
||||
? DatDirectoryValidation.Valid(fullPath)
|
||||
: DatDirectoryValidation.Invalid(
|
||||
fullPath,
|
||||
"The selected directory is missing required retail DAT files.",
|
||||
missing);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> DefaultWindowsCandidates()
|
||||
{
|
||||
string userProfile = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrWhiteSpace(userProfile))
|
||||
{
|
||||
yield return Path.Combine(
|
||||
userProfile,
|
||||
"Documents",
|
||||
"Asheron's Call");
|
||||
}
|
||||
|
||||
yield return @"C:\Turbine\Asheron's Call";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DatDirectoryValidation(
|
||||
string Directory,
|
||||
bool IsValid,
|
||||
string Message,
|
||||
IReadOnlyList<string> MissingFileNames)
|
||||
{
|
||||
internal static DatDirectoryValidation Valid(string directory) =>
|
||||
new(
|
||||
directory,
|
||||
true,
|
||||
"All four required retail DAT files were found.",
|
||||
[]);
|
||||
|
||||
internal static DatDirectoryValidation Invalid(
|
||||
string directory,
|
||||
string message,
|
||||
IReadOnlyList<string> missingFileNames) =>
|
||||
new(directory, false, message, missingFileNames);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-process ownership for every mutation or recovery of one launcher
|
||||
/// DataDirectory. The persistent lock pathname is harmless; exclusivity is
|
||||
/// owned by the open OS handle and therefore disappears if the process dies.
|
||||
/// </summary>
|
||||
internal sealed class InstallerTransactionLease : IAsyncDisposable
|
||||
{
|
||||
internal const string LockFileName = ".install.lock";
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private readonly FileStream _stream;
|
||||
|
||||
private InstallerTransactionLease(FileStream stream)
|
||||
{
|
||||
_stream = stream;
|
||||
}
|
||||
|
||||
internal static string GetLockPath(string dataDirectory) =>
|
||||
Path.Combine(Path.GetFullPath(dataDirectory), LockFileName);
|
||||
|
||||
internal static async ValueTask<InstallerTransactionLease> AcquireAsync(
|
||||
string dataDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string lockPath = GetLockPath(dataDirectory);
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(lockPath)
|
||||
?? throw new InvalidOperationException(
|
||||
"The installer lock path has no parent directory."));
|
||||
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var stream = new FileStream(
|
||||
lockPath,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None,
|
||||
bufferSize: 1,
|
||||
FileOptions.None);
|
||||
return new InstallerTransactionLease(stream);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
await Task.Delay(RetryDelay, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_stream.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,488 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
public enum InstallRecordVerificationState
|
||||
{
|
||||
Missing,
|
||||
Verified,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
public sealed record InstallRecordVerification(
|
||||
InstallRecordVerificationState State,
|
||||
LauncherInstallRecord? Record,
|
||||
string Status)
|
||||
{
|
||||
public bool IsVerified => State == InstallRecordVerificationState.Verified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Versioned install-record persistence and startup verification. The record
|
||||
/// is atomically replaced only after a complete package has been hashed; a
|
||||
/// crash during a reinstall can recover the prior verified pak from the
|
||||
/// adjacent backup before launch is enabled.
|
||||
/// </summary>
|
||||
public sealed class LauncherInstallRecordStore
|
||||
{
|
||||
public const uint CurrentBakeToolVersion = 4;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
};
|
||||
|
||||
private readonly ApplicationPathSet _paths;
|
||||
private readonly DatDirectoryLocator _datDirectories;
|
||||
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
|
||||
|
||||
public LauncherInstallRecordStore(
|
||||
ApplicationPathSet paths,
|
||||
DatDirectoryLocator? datDirectories = null,
|
||||
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
|
||||
{
|
||||
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
|
||||
_datDirectories = datDirectories ?? new DatDirectoryLocator();
|
||||
_computeSha256 = computeSha256
|
||||
?? ((path, cancellationToken) =>
|
||||
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
|
||||
}
|
||||
|
||||
public string DataDirectory => Path.GetFullPath(_paths.DataDirectory);
|
||||
|
||||
public string RecordPath => Path.Combine(DataDirectory, "install.json");
|
||||
|
||||
public string PreparedAssetPath => Path.Combine(
|
||||
DataDirectory,
|
||||
"pak",
|
||||
"acdream.pak");
|
||||
|
||||
public static string GetBackupPath(string preparedAssetPath) =>
|
||||
preparedAssetPath + ".previous-install";
|
||||
|
||||
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using InstallerTransactionLease lease =
|
||||
await InstallerTransactionLease.AcquireAsync(
|
||||
DataDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return await LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task<InstallRecordVerification> LoadAndVerifyUnderLeaseAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(RecordPath))
|
||||
{
|
||||
return new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Missing,
|
||||
null,
|
||||
"Client content is not installed. Complete the first-run setup.");
|
||||
}
|
||||
|
||||
LauncherInstallRecord? record;
|
||||
try
|
||||
{
|
||||
await using FileStream stream = new(
|
||||
RecordPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
using JsonDocument document = await JsonDocument.ParseAsync(
|
||||
stream,
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
JsonElement root = document.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("version", out JsonElement version)
|
||||
|| !version.TryGetInt32(out _))
|
||||
{
|
||||
return Invalid(
|
||||
"The install record must contain an explicit integer version.");
|
||||
}
|
||||
|
||||
record = root.Deserialize<LauncherInstallRecord>(SerializerOptions);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or JsonException
|
||||
or NotSupportedException)
|
||||
{
|
||||
return Invalid($"The install record could not be read: {ex.Message}");
|
||||
}
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
return Invalid("The install record is empty.");
|
||||
}
|
||||
|
||||
string? contractError = ValidateRecordContract(
|
||||
record,
|
||||
requireCanonicalSerializedPaths: true);
|
||||
if (contractError is not null)
|
||||
{
|
||||
return Invalid(contractError);
|
||||
}
|
||||
|
||||
string backupPath = GetBackupPath(record.PreparedAssetPath);
|
||||
FileVerification current = await VerifyFileAsync(
|
||||
record.PreparedAssetPath,
|
||||
record,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (current.IsValid)
|
||||
{
|
||||
TryDelete(backupPath);
|
||||
return Verified(record);
|
||||
}
|
||||
|
||||
// A process crash may occur after the old verified package was moved
|
||||
// aside but before the replacement record was published. Verify the
|
||||
// backup against the still-current record before restoring it.
|
||||
FileVerification backup = await VerifyFileAsync(
|
||||
backupPath,
|
||||
record,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (backup.IsValid)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(record.PreparedAssetPath)
|
||||
?? throw new InvalidOperationException(
|
||||
"The prepared asset path has no parent directory."));
|
||||
File.Move(backupPath, record.PreparedAssetPath, overwrite: true);
|
||||
return Verified(record, "Recovered and verified the previous client content.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return Invalid(
|
||||
$"The previous verified package could not be restored: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return Invalid(current.Status);
|
||||
}
|
||||
|
||||
public async Task SaveAtomicallyAsync(
|
||||
LauncherInstallRecord record,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using InstallerTransactionLease lease =
|
||||
await InstallerTransactionLease.AcquireAsync(
|
||||
DataDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await SaveAtomicallyUnderLeaseAsync(record, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task SaveAtomicallyUnderLeaseAsync(
|
||||
LauncherInstallRecord record,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
LauncherInstallRecord normalized = NormalizeForSave(record);
|
||||
string? contractError = ValidateRecordContract(
|
||||
normalized,
|
||||
requireCanonicalSerializedPaths: true);
|
||||
if (contractError is not null)
|
||||
{
|
||||
throw new InvalidDataException(contractError);
|
||||
}
|
||||
|
||||
string directory = Path.GetDirectoryName(RecordPath)
|
||||
?? throw new InvalidOperationException(
|
||||
"The install record has no parent directory.");
|
||||
Directory.CreateDirectory(directory);
|
||||
string temporaryPath = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(RecordPath)}.{Guid.NewGuid():N}.tmp");
|
||||
|
||||
try
|
||||
{
|
||||
await using (FileStream stream = new(
|
||||
temporaryPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 4096,
|
||||
options: FileOptions.Asynchronous | FileOptions.WriteThrough))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(
|
||||
stream,
|
||||
normalized,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
File.Move(temporaryPath, RecordPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
private string? ValidateRecordContract(
|
||||
LauncherInstallRecord record,
|
||||
bool requireCanonicalSerializedPaths)
|
||||
{
|
||||
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
|
||||
{
|
||||
return $"Install record version {record.Version} is not supported.";
|
||||
}
|
||||
|
||||
if (!record.HasIntegrityMetadata)
|
||||
{
|
||||
return "The install record is missing SHA-256, size, or bake-tool metadata.";
|
||||
}
|
||||
|
||||
if (record.BakeToolVersion != CurrentBakeToolVersion)
|
||||
{
|
||||
return $"Bake tool version {record.BakeToolVersion} is not supported; "
|
||||
+ $"version {CurrentBakeToolVersion} is required.";
|
||||
}
|
||||
|
||||
if (!IsSha256(record.PreparedAssetSha256))
|
||||
{
|
||||
return "The install record contains an invalid SHA-256 digest.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
|
||||
{
|
||||
return "The prepared asset path is missing.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(record.DatDirectory))
|
||||
{
|
||||
return "The DAT directory path is missing.";
|
||||
}
|
||||
|
||||
string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath);
|
||||
if (requireCanonicalSerializedPaths
|
||||
&& !Path.IsPathFullyQualified(record.PreparedAssetPath))
|
||||
{
|
||||
return "The prepared asset path must be absolute.";
|
||||
}
|
||||
|
||||
string recordedPreparedPath;
|
||||
try
|
||||
{
|
||||
recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException
|
||||
or NotSupportedException
|
||||
or PathTooLongException)
|
||||
{
|
||||
return $"The prepared asset path is invalid: {ex.Message}";
|
||||
}
|
||||
|
||||
if (!PathsEqual(recordedPreparedPath, canonicalPreparedPath))
|
||||
{
|
||||
return "The install record does not point to the launcher's canonical "
|
||||
+ "DataDirectory/pak/acdream.pak path.";
|
||||
}
|
||||
|
||||
if (requireCanonicalSerializedPaths
|
||||
&& !CanonicalSpellingEquals(
|
||||
record.PreparedAssetPath,
|
||||
recordedPreparedPath,
|
||||
trimEndingSeparator: false))
|
||||
{
|
||||
return "The prepared asset path is not canonical.";
|
||||
}
|
||||
|
||||
if (requireCanonicalSerializedPaths
|
||||
&& !Path.IsPathFullyQualified(record.DatDirectory))
|
||||
{
|
||||
return "The DAT directory path must be absolute.";
|
||||
}
|
||||
|
||||
DatDirectoryValidation datValidation =
|
||||
_datDirectories.Validate(record.DatDirectory);
|
||||
if (!datValidation.IsValid)
|
||||
{
|
||||
return datValidation.Message
|
||||
+ FormatMissing(datValidation.MissingFileNames);
|
||||
}
|
||||
|
||||
return requireCanonicalSerializedPaths
|
||||
&& !CanonicalSpellingEquals(
|
||||
record.DatDirectory,
|
||||
datValidation.Directory,
|
||||
trimEndingSeparator: true)
|
||||
? "The DAT directory path is not canonical."
|
||||
: null;
|
||||
}
|
||||
|
||||
private LauncherInstallRecord NormalizeForSave(LauncherInstallRecord record)
|
||||
{
|
||||
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Install record version {record.Version} is not supported.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(record.PreparedAssetPath))
|
||||
{
|
||||
throw new InvalidDataException("The prepared asset path is missing.");
|
||||
}
|
||||
|
||||
DatDirectoryValidation datValidation =
|
||||
_datDirectories.Validate(record.DatDirectory);
|
||||
if (!datValidation.IsValid)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
datValidation.Message
|
||||
+ FormatMissing(datValidation.MissingFileNames));
|
||||
}
|
||||
|
||||
string recordedPreparedPath;
|
||||
try
|
||||
{
|
||||
recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException
|
||||
or NotSupportedException
|
||||
or PathTooLongException)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The prepared asset path is invalid: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
|
||||
if (!PathsEqual(recordedPreparedPath, PreparedAssetPath))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"The install record does not point to the launcher's canonical "
|
||||
+ "DataDirectory/pak/acdream.pak path.");
|
||||
}
|
||||
|
||||
return record with
|
||||
{
|
||||
Version = LauncherInstallRecord.CurrentRecordVersion,
|
||||
DatDirectory = datValidation.Directory,
|
||||
PreparedAssetPath = Path.GetFullPath(PreparedAssetPath),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<FileVerification> VerifyFileAsync(
|
||||
string path,
|
||||
LauncherInstallRecord record,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new FileVerification(false, "The prepared package is missing.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
long length = new FileInfo(path).Length;
|
||||
if (length != record.PreparedAssetSize)
|
||||
{
|
||||
return new FileVerification(
|
||||
false,
|
||||
$"The prepared package size changed (expected "
|
||||
+ $"{record.PreparedAssetSize}, found {length}).");
|
||||
}
|
||||
|
||||
string sha256 = await _computeSha256(path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return FileIntegrity.Matches(sha256, record.PreparedAssetSha256)
|
||||
? new FileVerification(true, "Client content verified.")
|
||||
: new FileVerification(
|
||||
false,
|
||||
"The prepared package SHA-256 does not match the install record.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new FileVerification(
|
||||
false,
|
||||
$"The prepared package could not be verified: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static InstallRecordVerification Verified(
|
||||
LauncherInstallRecord record,
|
||||
string status = "Client content SHA-256, size, and bake-tool version verified.") =>
|
||||
new(InstallRecordVerificationState.Verified, record, status);
|
||||
|
||||
private static InstallRecordVerification Invalid(string status) =>
|
||||
new(InstallRecordVerificationState.Invalid, null, status);
|
||||
|
||||
private static bool IsSha256(string value) =>
|
||||
value.Length == 64 && value.All(Uri.IsHexDigit);
|
||||
|
||||
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.TrimEndingDirectorySeparator(left),
|
||||
Path.TrimEndingDirectorySeparator(right),
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
|
||||
private static bool CanonicalSpellingEquals(
|
||||
string serialized,
|
||||
string canonical,
|
||||
bool trimEndingSeparator)
|
||||
{
|
||||
string candidate = trimEndingSeparator
|
||||
? Path.TrimEndingDirectorySeparator(serialized)
|
||||
: serialized;
|
||||
return string.Equals(
|
||||
candidate,
|
||||
canonical,
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
internal static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A stale temp/backup is never treated as a published record. The
|
||||
// next startup verification retries cleanup/recovery.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record FileVerification(bool IsValid, string Status);
|
||||
}
|
||||
561
src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs
Normal file
561
src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -27,6 +27,11 @@ public static class FileIntegrity
|
|||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronous, cancellable counterpart used while verifying a multi-
|
||||
/// gigabyte prepared package. The file stays streamed and no buffer is
|
||||
/// retained after the hash completes.
|
||||
/// </summary>
|
||||
public static async Task<string> ComputeSha256HexAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
|
@ -38,8 +43,8 @@ public static class FileIntegrity
|
|||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
useAsync: true);
|
||||
bufferSize: 1024 * 1024,
|
||||
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,24 @@ namespace AcDream.Launcher.Core.Launching;
|
|||
/// <summary>
|
||||
/// The DAT/pak locations a completed install (LA9) records and every
|
||||
/// session-config composition consumes for
|
||||
/// <see cref="SessionContentDescriptor"/>. SHA-256/version bookkeeping
|
||||
/// for the install record itself is LA9/LA10 scope; this slice only
|
||||
/// needs the two paths a session config requires.
|
||||
/// <see cref="SessionContentDescriptor"/>. Integrity metadata is launcher-
|
||||
/// local: it is verified before this record is admitted to the orchestrator
|
||||
/// and is deliberately not copied into the host session-config contract.
|
||||
/// </summary>
|
||||
public sealed record LauncherInstallRecord(
|
||||
string DatDirectory,
|
||||
string PreparedAssetPath);
|
||||
string PreparedAssetPath,
|
||||
string PreparedAssetSha256 = "",
|
||||
long PreparedAssetSize = 0,
|
||||
uint BakeToolVersion = 0)
|
||||
{
|
||||
public const int CurrentRecordVersion = 1;
|
||||
|
||||
public int Version { get; init; } = CurrentRecordVersion;
|
||||
|
||||
public bool HasIntegrityMetadata =>
|
||||
!string.IsNullOrEmpty(PreparedAssetSha256)
|
||||
&& PreparedAssetSha256.Length == 64
|
||||
&& PreparedAssetSize > 0
|
||||
&& BakeToolVersion > 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
private readonly List<ManagedActivity> _activities = [];
|
||||
|
||||
private LauncherInstallRecord? _installRecord;
|
||||
private string _installationStatus;
|
||||
private bool _disposed;
|
||||
|
||||
public LauncherOrchestrator(
|
||||
|
|
@ -40,7 +41,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
ILauncherSessionConfigService? configService = null,
|
||||
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
||||
IStatusEventSourceFactory? statusSourceFactory = null,
|
||||
Func<string>? sessionIdFactory = null)
|
||||
Func<string>? sessionIdFactory = null,
|
||||
string? installationStatus = null)
|
||||
{
|
||||
_profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
|
||||
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
|
||||
|
|
@ -51,6 +53,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
_supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory();
|
||||
_statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory();
|
||||
_sessionIdFactory = sessionIdFactory ?? CreateSessionId;
|
||||
_installationStatus = installationStatus
|
||||
?? (installRecord is null
|
||||
? FirstRunRequired
|
||||
: "Client content paths are configured.");
|
||||
}
|
||||
|
||||
public event EventHandler? StateChanged;
|
||||
|
|
@ -85,9 +91,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
sessions,
|
||||
_platform,
|
||||
_installRecord is not null,
|
||||
_installRecord is null
|
||||
? FirstRunRequired
|
||||
: "Client content paths are configured.");
|
||||
_installationStatus);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +188,9 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
{
|
||||
ThrowIfDisposed();
|
||||
_installRecord = installRecord;
|
||||
_installationStatus = installRecord is null
|
||||
? FirstRunRequired
|
||||
: "Client content SHA-256, size, and bake-tool version verified.";
|
||||
}
|
||||
|
||||
RaiseStateChanged();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue