feat(launcher): LU1 — stop hashing 28 GB before the launcher window appears
Measured on the user's machine: %LOCALAPPDATA%\acdream\pak\acdream.pak is 29,908,271,024 bytes and SHA-256 over it takes 24.1 s at 1.16 GB/s. App .OnFrameworkInitializationCompleted ran exactly that hash synchronously, before constructing the window, and the digest came back identical to the one install.json already recorded. So the launcher took roughly half a minute to appear in order to re-confirm a fact that had not changed. A friend does not see it only because they have no package installed yet — verification short-circuits at "nothing installed" — so it would hit them the moment first-run setup finished. Startup now checks the cheap facts (size, last-write time) and skips only the hash, and only when a previous FULL hash of that same file agreed with the install record. Everything that should hash still does: install, update, the crash-recovery backup path, and a new explicit "Verify files" button. The remembered fact lives in a SIDECAR (install.verification.json), not as a new field on the install record: LauncherInstallRecordStore reads install.json with JsonUnmappedMemberHandling.Disallow, so a new property there would make an older launcher build reject the record outright and demand a fresh ~28 GB bake after a rollback. An unknown sidecar is simply ignored by builds that predate it. The cache type never throws — it sits in front of a guarantee, so every failure mode (missing, corrupt, unknown schema, unwritable) degrades to "hash it again" rather than to a failed launch. Two subtleties worth keeping: - The write time is re-read after the hash and the entry is only written when it is unchanged. A writer racing a multi-second hash would otherwise be remembered under the OLD timestamp, and the next startup would trust a digest that never covered those bytes. - A hash that disagrees with the record invalidates the entry, so a stale "verified" fact cannot outlive the evidence that produced it. Tests: PreparedAssetVerificationCacheTests (10) counts hash invocations through the store's injectable hasher and covers second-startup skip, forced full verification, touched package, same-size silent corruption, resize, a cache digest that disagrees with the record, three unreadable-cache shapes, and backup recovery still hashing. Plus two LauncherWindowViewModel tests for the Verify files command. Launcher.Core 335 passed, Launcher 57 passed. Note for the first run after this ships: the very first startup still pays one full hash to learn the digest for the installed file, and every startup after that is instant. Campaign LU slice LU1. Plan: docs/plans/2026-08-19-launcher-usability-campaign.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a34e8f2a17
commit
00d1278228
8 changed files with 665 additions and 22 deletions
|
|
@ -41,6 +41,7 @@ public sealed class LauncherInstallRecordStore
|
|||
private readonly ApplicationPathSet _paths;
|
||||
private readonly DatDirectoryLocator _datDirectories;
|
||||
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
|
||||
private readonly PreparedAssetVerificationCache _verificationCache;
|
||||
|
||||
public LauncherInstallRecordStore(
|
||||
ApplicationPathSet paths,
|
||||
|
|
@ -52,6 +53,7 @@ public sealed class LauncherInstallRecordStore
|
|||
_computeSha256 = computeSha256
|
||||
?? ((path, cancellationToken) =>
|
||||
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
|
||||
_verificationCache = new PreparedAssetVerificationCache(DataDirectory);
|
||||
}
|
||||
|
||||
public string DataDirectory => Path.GetFullPath(_paths.DataDirectory);
|
||||
|
|
@ -66,20 +68,28 @@ public sealed class LauncherInstallRecordStore
|
|||
public static string GetBackupPath(string preparedAssetPath) =>
|
||||
preparedAssetPath + ".previous-install";
|
||||
|
||||
/// <param name="forceFullVerification">Hash the package even when a
|
||||
/// previous full hash of the same bytes is remembered. Install, update,
|
||||
/// and any explicit "verify my files" request pass true; ordinary startup
|
||||
/// passes false.</param>
|
||||
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false)
|
||||
{
|
||||
await using InstallerTransactionLease lease =
|
||||
await InstallerTransactionLease.AcquireAsync(
|
||||
DataDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return await LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||
return await LoadAndVerifyUnderLeaseAsync(
|
||||
cancellationToken,
|
||||
forceFullVerification)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task<InstallRecordVerification> LoadAndVerifyUnderLeaseAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false)
|
||||
{
|
||||
if (!File.Exists(RecordPath))
|
||||
{
|
||||
|
|
@ -143,7 +153,8 @@ public sealed class LauncherInstallRecordStore
|
|||
FileVerification current = await VerifyFileAsync(
|
||||
record.PreparedAssetPath,
|
||||
record,
|
||||
cancellationToken)
|
||||
cancellationToken,
|
||||
allowCachedResult: !forceFullVerification)
|
||||
.ConfigureAwait(false);
|
||||
if (current.IsValid)
|
||||
{
|
||||
|
|
@ -154,10 +165,15 @@ public sealed class LauncherInstallRecordStore
|
|||
// 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.
|
||||
// The backup is a RECOVERY path: it runs only because the live package
|
||||
// just failed, and it decides whether to move a file into that
|
||||
// package's place. It always hashes — a cache entry describes the live
|
||||
// package, never this one.
|
||||
FileVerification backup = await VerifyFileAsync(
|
||||
backupPath,
|
||||
record,
|
||||
cancellationToken)
|
||||
cancellationToken,
|
||||
allowCachedResult: false)
|
||||
.ConfigureAwait(false);
|
||||
if (backup.IsValid)
|
||||
{
|
||||
|
|
@ -387,10 +403,23 @@ public sealed class LauncherInstallRecordStore
|
|||
};
|
||||
}
|
||||
|
||||
/// <param name="allowCachedResult">
|
||||
/// When true, a matching <see cref="PreparedAssetVerificationCache"/> entry
|
||||
/// stands in for the hash. Hashing the prepared package is proportional to
|
||||
/// its size, and it is very large — 27.9 GiB and 24.1 s on the machine this
|
||||
/// was measured on — so doing it on every ordinary startup made the
|
||||
/// launcher take half a minute to appear while re-confirming a fact that
|
||||
/// had not changed. The cheap facts (size, last-write time) still run
|
||||
/// unconditionally; only the hash is skipped, and only when a previous full
|
||||
/// hash of that same file agreed with this record.
|
||||
/// Pass false for the install/update paths and for any explicit
|
||||
/// verify-my-files request, which must always hash.
|
||||
/// </param>
|
||||
private async Task<FileVerification> VerifyFileAsync(
|
||||
string path,
|
||||
LauncherInstallRecord record,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
bool allowCachedResult)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
|
|
@ -399,7 +428,8 @@ public sealed class LauncherInstallRecordStore
|
|||
|
||||
try
|
||||
{
|
||||
long length = new FileInfo(path).Length;
|
||||
var file = new FileInfo(path);
|
||||
long length = file.Length;
|
||||
if (length != record.PreparedAssetSize)
|
||||
{
|
||||
return new FileVerification(
|
||||
|
|
@ -408,13 +438,42 @@ public sealed class LauncherInstallRecordStore
|
|||
+ $"{record.PreparedAssetSize}, found {length}).");
|
||||
}
|
||||
|
||||
DateTime lastWriteUtc = file.LastWriteTimeUtc;
|
||||
if (allowCachedResult
|
||||
&& PreparedAssetVerificationCache.Satisfies(
|
||||
_verificationCache.TryRead(),
|
||||
path,
|
||||
length,
|
||||
lastWriteUtc,
|
||||
record.PreparedAssetSha256))
|
||||
{
|
||||
return new FileVerification(true, "Client content verified.");
|
||||
}
|
||||
|
||||
string sha256 = await _computeSha256(path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return FileIntegrity.Matches(sha256, record.PreparedAssetSha256)
|
||||
? new FileVerification(true, "Client content verified.")
|
||||
: new FileVerification(
|
||||
if (!FileIntegrity.Matches(sha256, record.PreparedAssetSha256))
|
||||
{
|
||||
// The remembered fact, if any, is now known to be wrong about
|
||||
// this file. Drop it rather than leaving a stale "verified"
|
||||
// entry that a later startup could believe.
|
||||
_verificationCache.Invalidate();
|
||||
return new FileVerification(
|
||||
false,
|
||||
"The prepared package SHA-256 does not match the install record.");
|
||||
}
|
||||
|
||||
// Re-read the write time: a concurrent writer between the stat
|
||||
// above and the end of a multi-second hash would otherwise be
|
||||
// remembered under the OLD timestamp, and the next startup would
|
||||
// trust the cache for a file the hash never actually covered.
|
||||
DateTime hashedWriteUtc = new FileInfo(path).LastWriteTimeUtc;
|
||||
if (hashedWriteUtc == lastWriteUtc)
|
||||
{
|
||||
_verificationCache.Write(path, length, lastWriteUtc, sha256);
|
||||
}
|
||||
|
||||
return new FileVerification(true, "Client content verified.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -52,8 +52,14 @@ public interface ILauncherInstaller
|
|||
|
||||
DatDirectoryValidation ValidateDatDirectory(string? directory);
|
||||
|
||||
/// <param name="forceFullVerification">Hash the installed package even
|
||||
/// when a previous full hash of the same bytes is remembered. Ordinary
|
||||
/// startup passes false so the launcher window is not held behind a
|
||||
/// multi-second hash of a very large file; an explicit "verify my files"
|
||||
/// request passes true.</param>
|
||||
Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false);
|
||||
|
||||
Task<LauncherInstallResult> InstallAsync(
|
||||
string datDirectory,
|
||||
|
|
@ -115,7 +121,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
_datDirectories.Validate(directory);
|
||||
|
||||
public async Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false)
|
||||
{
|
||||
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
|
|
@ -128,7 +135,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
.ConfigureAwait(false);
|
||||
InstallRecordVerification verification =
|
||||
await RecoverExistingUnderPublicationGuardAsync(
|
||||
cancellationToken)
|
||||
cancellationToken,
|
||||
forceFullVerification)
|
||||
.ConfigureAwait(false);
|
||||
_verifiedRecord = verification.Record;
|
||||
return verification;
|
||||
|
|
@ -204,7 +212,13 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
string outputPath = _recordStore.PreparedAssetPath;
|
||||
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
|
||||
InstallRecordVerification existing =
|
||||
await RecoverExistingUnderPublicationGuardAsync(cancellationToken)
|
||||
await RecoverExistingUnderPublicationGuardAsync(
|
||||
cancellationToken,
|
||||
// An install is about to replace this package, and this
|
||||
// call decides whether the PRIOR one can be recovered.
|
||||
// That decision must rest on real bytes, never on a
|
||||
// remembered digest.
|
||||
forceFullVerification: true)
|
||||
.ConfigureAwait(false);
|
||||
_verifiedRecord = existing.Record;
|
||||
|
||||
|
|
@ -441,7 +455,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
|
||||
private async Task<InstallRecordVerification>
|
||||
RecoverExistingUnderPublicationGuardAsync(
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
bool forceFullVerification = false)
|
||||
{
|
||||
string outputPath = _recordStore.PreparedAssetPath;
|
||||
await using BakePublicationGuardContract.PublicationLease publication =
|
||||
|
|
@ -455,7 +470,9 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
// promotion before recovery reaches this invalidation point.
|
||||
BakePublicationGuardContract.Invalidate(outputPath, publication);
|
||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||
return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||
return await _recordStore.LoadAndVerifyUnderLeaseAsync(
|
||||
cancellationToken,
|
||||
forceFullVerification)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>
|
||||
/// One remembered "this exact file already hashed to this digest" fact.
|
||||
/// <see cref="Sha256"/> is stored so the entry can be matched against the
|
||||
/// install record's own digest — an entry that agrees with the file on disk
|
||||
/// but disagrees with the record must never satisfy verification.
|
||||
/// </summary>
|
||||
internal sealed record PreparedAssetVerificationEntry
|
||||
{
|
||||
public required int Version { get; init; }
|
||||
|
||||
public required string Path { get; init; }
|
||||
|
||||
public required long Size { get; init; }
|
||||
|
||||
public required long LastWriteUtcTicks { get; init; }
|
||||
|
||||
public required string Sha256 { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the result of a full package hash so ordinary startup does not
|
||||
/// have to repeat it.
|
||||
///
|
||||
/// <para><b>Why this is a sidecar and not a field on the install record.</b>
|
||||
/// <see cref="LauncherInstallRecordStore"/> reads <c>install.json</c> with
|
||||
/// <see cref="JsonUnmappedMemberHandling.Disallow"/>, so adding a property
|
||||
/// there would make an OLDER launcher build reject the record outright and
|
||||
/// demand a fresh ~28 GB bake after a rollback. An unknown sidecar file is
|
||||
/// simply ignored by builds that predate it, which keeps the change
|
||||
/// compatible in both directions — and a launcher that ignores the cache
|
||||
/// merely hashes, which is the behavior that existed before.</para>
|
||||
///
|
||||
/// <para><b>This type never throws.</b> It is an optimization sitting in
|
||||
/// front of a guarantee; a cache that could fail would turn a missing or
|
||||
/// corrupt scratch file into a failed launch. Reads return null on anything
|
||||
/// unexpected and writes swallow IO failures, so every failure mode
|
||||
/// degrades to "hash it again".</para>
|
||||
/// </summary>
|
||||
internal sealed class PreparedAssetVerificationCache
|
||||
{
|
||||
internal const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
private readonly string _path;
|
||||
|
||||
public PreparedAssetVerificationCache(string dataDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
_path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetFullPath(dataDirectory),
|
||||
"install.verification.json");
|
||||
}
|
||||
|
||||
public string CachePath => _path;
|
||||
|
||||
/// <summary>
|
||||
/// The remembered entry, or null when there is none, it cannot be read,
|
||||
/// it was written by a schema this build does not know, or it is
|
||||
/// internally incomplete.
|
||||
/// </summary>
|
||||
public PreparedAssetVerificationEntry? TryRead()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using FileStream stream = File.OpenRead(_path);
|
||||
PreparedAssetVerificationEntry? entry =
|
||||
JsonSerializer.Deserialize<PreparedAssetVerificationEntry>(
|
||||
stream,
|
||||
SerializerOptions);
|
||||
if (entry is null
|
||||
|| entry.Version != CurrentVersion
|
||||
|| string.IsNullOrWhiteSpace(entry.Path)
|
||||
|| string.IsNullOrWhiteSpace(entry.Sha256)
|
||||
|| entry.Size <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or JsonException
|
||||
or NotSupportedException
|
||||
or ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that <paramref name="path"/>, at this size and write time,
|
||||
/// hashed to <paramref name="sha256"/>. Written through a temporary file
|
||||
/// so an interrupted write cannot leave a half-parsed entry behind.
|
||||
/// </summary>
|
||||
public void Write(string path, long size, DateTime lastWriteUtc, string sha256)
|
||||
{
|
||||
var entry = new PreparedAssetVerificationEntry
|
||||
{
|
||||
Version = CurrentVersion,
|
||||
Path = System.IO.Path.GetFullPath(path),
|
||||
Size = size,
|
||||
LastWriteUtcTicks = lastWriteUtc.Ticks,
|
||||
Sha256 = sha256,
|
||||
};
|
||||
|
||||
string temporaryPath = _path + ".tmp";
|
||||
try
|
||||
{
|
||||
string? directory = System.IO.Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
temporaryPath,
|
||||
JsonSerializer.Serialize(entry, SerializerOptions));
|
||||
File.Move(temporaryPath, _path, overwrite: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or ArgumentException)
|
||||
{
|
||||
TryDelete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forgets the remembered entry. Called when a full hash
|
||||
/// disagrees with the install record, so a stale "verified" fact can
|
||||
/// never outlive the evidence that produced it.</summary>
|
||||
public void Invalidate() => TryDelete(_path);
|
||||
|
||||
/// <summary>
|
||||
/// True when this entry can stand in for a full hash of the file
|
||||
/// currently on disk: same file, same size, same write time, and a digest
|
||||
/// that still agrees with the install record.
|
||||
/// </summary>
|
||||
public static bool Satisfies(
|
||||
PreparedAssetVerificationEntry? entry,
|
||||
string path,
|
||||
long size,
|
||||
DateTime lastWriteUtc,
|
||||
string recordedSha256) =>
|
||||
entry is not null
|
||||
&& string.Equals(
|
||||
entry.Path,
|
||||
System.IO.Path.GetFullPath(path),
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal)
|
||||
&& entry.Size == size
|
||||
&& entry.LastWriteUtcTicks == lastWriteUtc.Ticks
|
||||
&& Integrity.FileIntegrity.Matches(entry.Sha256, recordedSha256);
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or NotSupportedException
|
||||
or ArgumentException)
|
||||
{
|
||||
// Best effort by contract — see the type doc.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,9 @@
|
|||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="First-run setup"
|
||||
Command="{Binding FirstRunWizardShell.OpenCommand}" />
|
||||
<Button Content="Verify files"
|
||||
AutomationProperties.Name="Verify installed client content"
|
||||
Command="{Binding VerifyContentCommand}" />
|
||||
<Button Content="Check for updates"
|
||||
Command="{Binding UpdatePrompt.OpenCommand}" />
|
||||
</StackPanel>
|
||||
|
|
|
|||
|
|
@ -367,7 +367,8 @@ internal sealed class UnavailableLauncherInstaller : ILauncherInstaller
|
|||
DatDirectoryLocator.RequiredFileNames);
|
||||
|
||||
public Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false) =>
|
||||
Task.FromResult(new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Missing,
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
{
|
||||
private readonly ILauncherOrchestrator _orchestrator;
|
||||
private readonly IUiDispatcher _dispatcher;
|
||||
private readonly ILauncherInstaller _installer;
|
||||
private LauncherStateSnapshot? _snapshot;
|
||||
private LauncherTreeNodeViewModel? _selectedNode;
|
||||
private CancellationTokenSource? _operationCancellation;
|
||||
|
|
@ -34,8 +35,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
_orchestrator.StateChanged += OnOrchestratorStateChanged;
|
||||
|
||||
EditorDialog = new ProfileEditorDialogViewModel();
|
||||
_installer = installer ?? new UnavailableLauncherInstaller();
|
||||
FirstRunWizardShell = new FirstRunInstallerViewModel(
|
||||
installer ?? new UnavailableLauncherInstaller(),
|
||||
_installer,
|
||||
dispatcher,
|
||||
OnInstallCompleted,
|
||||
() => CanInteract,
|
||||
|
|
@ -80,6 +82,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
ClearFinishedSessionsCommand = new RelayCommand(
|
||||
_orchestrator.ClearFinishedSessions,
|
||||
() => Sessions.Any(session => !session.IsActive) && CanInteract);
|
||||
VerifyContentCommand = new AsyncRelayCommand(
|
||||
VerifyContentAsync,
|
||||
() => CanInteract && Sessions.All(session => !session.IsActive));
|
||||
}
|
||||
|
||||
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
|
||||
|
|
@ -284,6 +289,10 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
public AsyncRelayCommand LaunchHeadlessCommand { get; }
|
||||
|
||||
/// <summary>LU1's explicit "verify files" action — see
|
||||
/// <see cref="VerifyContentAsync"/>.</summary>
|
||||
public AsyncRelayCommand VerifyContentCommand { get; }
|
||||
|
||||
public RelayCommand CancelOperationCommand { get; }
|
||||
|
||||
public RelayCommand ClearFinishedSessionsCommand { get; }
|
||||
|
|
@ -972,6 +981,58 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
: message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LU1: the deliberate, user-asked-for full hash of the installed package.
|
||||
/// Ordinary startup deliberately does NOT do this (it trusts a remembered
|
||||
/// digest for a file whose size and write time are unchanged), so this is
|
||||
/// the way to make the launcher actually re-read all ~28 GB and prove the
|
||||
/// content is intact — the same shape as a game client's "verify files".
|
||||
///
|
||||
/// <para>A failed verification clears the install record, which disables
|
||||
/// launching. That is the point: the launcher must not start a client
|
||||
/// against content it just proved is wrong.</para>
|
||||
/// </summary>
|
||||
private async Task VerifyContentAsync()
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_operationCancellation = cancellation;
|
||||
IsBusy = true;
|
||||
LastError = null;
|
||||
OperationStatus = "Verifying client content… this reads the whole package.";
|
||||
try
|
||||
{
|
||||
InstallRecordVerification verification = await _installer
|
||||
.LoadExistingAsync(cancellation.Token, forceFullVerification: true)
|
||||
.ConfigureAwait(true);
|
||||
_orchestrator.SetInstallRecord(verification.Record);
|
||||
OperationStatus = verification.Status;
|
||||
if (!verification.IsVerified)
|
||||
{
|
||||
LastError = verification.Status;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
OperationStatus = "Verification cancelled.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LastError = SafeDisplayError(ex, secret: null);
|
||||
OperationStatus = "Verification failed.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationCancellation = null;
|
||||
IsBusy = false;
|
||||
RefreshFromCore();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnInstallCompleted(LauncherInstallRecord record)
|
||||
{
|
||||
_orchestrator.SetInstallRecord(record);
|
||||
|
|
@ -1002,6 +1063,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
LaunchHeadlessCommand.NotifyCanExecuteChanged();
|
||||
CancelOperationCommand.NotifyCanExecuteChanged();
|
||||
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
|
||||
VerifyContentCommand.NotifyCanExecuteChanged();
|
||||
foreach (LauncherSessionRowViewModel session in Sessions)
|
||||
{
|
||||
session.NotifyCommandState();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||
|
||||
/// <summary>
|
||||
/// LU1. Ordinary startup used to SHA-256 the whole installed package before
|
||||
/// the launcher window was constructed. That package is ~28 GiB in practice
|
||||
/// and the hash measured 24.1 s, so the launcher took roughly half a minute
|
||||
/// to appear while re-confirming a digest that had not changed since the
|
||||
/// install wrote it.
|
||||
///
|
||||
/// <para>These tests pin the replacement: the cheap facts (size, last-write
|
||||
/// time) still run on every startup, the hash is skipped only when a previous
|
||||
/// full hash of that same file agreed with the install record, and every way
|
||||
/// that agreement can be false falls back to hashing.</para>
|
||||
/// </summary>
|
||||
[Collection(WorkingDirectoryCollection.Name)]
|
||||
public sealed class PreparedAssetVerificationCacheTests : IDisposable
|
||||
{
|
||||
private const string PackageContent = "verified package";
|
||||
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-verification-cache-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
private readonly ApplicationPathSet _paths;
|
||||
private readonly string _dats;
|
||||
private int _hashCount;
|
||||
|
||||
public PreparedAssetVerificationCacheTests()
|
||||
{
|
||||
_paths = new ApplicationPathSet(
|
||||
Path.Combine(_root, "config"),
|
||||
Path.Combine(_root, "data"),
|
||||
Path.Combine(_root, "cache"),
|
||||
null);
|
||||
_dats = Path.Combine(_root, "retail-dats");
|
||||
Directory.CreateDirectory(_dats);
|
||||
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_dats, fileName), "fixture");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondStartupSkipsTheHashEntirely()
|
||||
{
|
||||
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
||||
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(1, _hashCount);
|
||||
|
||||
// The whole point of the slice: nothing is read from the package the
|
||||
// second time around.
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(1, _hashCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForcedFullVerificationHashesEvenWithAValidCache()
|
||||
{
|
||||
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(1, _hashCount);
|
||||
|
||||
Assert.True((await store.LoadAndVerifyAsync(forceFullVerification: true))
|
||||
.IsVerified);
|
||||
Assert.Equal(2, _hashCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATouchedPackageIsHashedAgain()
|
||||
{
|
||||
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(1, _hashCount);
|
||||
|
||||
// Same bytes, new write time: the cheap facts no longer match, so the
|
||||
// expensive one has to run. It still verifies, because the content is
|
||||
// in fact unchanged.
|
||||
File.SetLastWriteTimeUtc(
|
||||
store.PreparedAssetPath,
|
||||
File.GetLastWriteTimeUtc(store.PreparedAssetPath).AddMinutes(5));
|
||||
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(2, _hashCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASilentlyCorruptedPackageOfTheSameSizeStillFailsAndDropsTheCache()
|
||||
{
|
||||
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
|
||||
// Identical length so the size check cannot catch it, and a fresh
|
||||
// write time so the cache cannot be believed.
|
||||
await File.WriteAllTextAsync(
|
||||
store.PreparedAssetPath,
|
||||
new string('x', PackageContent.Length));
|
||||
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
|
||||
Assert.False(verification.IsVerified);
|
||||
Assert.Contains("SHA-256", verification.Status, StringComparison.Ordinal);
|
||||
Assert.False(File.Exists(CachePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AResizedPackageIsRejectedWithoutHashingIt()
|
||||
{
|
||||
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(1, _hashCount);
|
||||
|
||||
await File.WriteAllTextAsync(store.PreparedAssetPath, PackageContent + "!");
|
||||
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
|
||||
Assert.False(verification.IsVerified);
|
||||
Assert.Contains("size changed", verification.Status, StringComparison.Ordinal);
|
||||
Assert.Equal(1, _hashCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ACacheWhoseDigestDisagreesWithTheRecordIsIgnored()
|
||||
{
|
||||
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(1, _hashCount);
|
||||
|
||||
// A cache entry that matches the FILE but not the RECORD must never
|
||||
// satisfy verification — otherwise a stale entry could vouch for a
|
||||
// package the current record does not describe.
|
||||
await File.WriteAllTextAsync(
|
||||
CachePath,
|
||||
(await File.ReadAllTextAsync(CachePath)).Replace(
|
||||
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||
new string('a', 64),
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(2, _hashCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("{ not json")]
|
||||
[InlineData("{\"version\":9999}")]
|
||||
public async Task AnUnreadableCacheDegradesToHashingRatherThanFailing(string content)
|
||||
{
|
||||
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(1, _hashCount);
|
||||
|
||||
await File.WriteAllTextAsync(CachePath, content);
|
||||
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(2, _hashCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupRecoveryHashesTheBackupEvenWhenTheLiveCacheIsValid()
|
||||
{
|
||||
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
||||
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||
Assert.Equal(1, _hashCount);
|
||||
|
||||
// Crash shape: the verified package was moved aside and what sits in
|
||||
// its place is wrong. The cache still describes the ORIGINAL bytes, so
|
||||
// if recovery trusted it the launcher would accept a bad package.
|
||||
string backup = LauncherInstallRecordStore.GetBackupPath(store.PreparedAssetPath);
|
||||
File.Move(store.PreparedAssetPath, backup);
|
||||
await File.WriteAllTextAsync(
|
||||
store.PreparedAssetPath,
|
||||
new string('z', PackageContent.Length));
|
||||
|
||||
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||
|
||||
Assert.True(verification.IsVerified);
|
||||
Assert.Equal(PackageContent, await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||
Assert.False(File.Exists(backup));
|
||||
// Live package hashed (and failed), then the backup hashed.
|
||||
Assert.Equal(3, _hashCount);
|
||||
}
|
||||
|
||||
private string CachePath => Path.Combine(
|
||||
Path.GetFullPath(_paths.DataDirectory),
|
||||
"install.verification.json");
|
||||
|
||||
private async Task<LauncherInstallRecordStore> CreateInstalledStoreAsync()
|
||||
{
|
||||
var store = new LauncherInstallRecordStore(
|
||||
_paths,
|
||||
datDirectories: null,
|
||||
computeSha256: (path, cancellationToken) =>
|
||||
{
|
||||
Interlocked.Increment(ref _hashCount);
|
||||
return FileIntegrity.ComputeSha256HexAsync(path, cancellationToken);
|
||||
});
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||
await File.WriteAllTextAsync(store.PreparedAssetPath, PackageContent);
|
||||
var info = new FileInfo(store.PreparedAssetPath);
|
||||
await store.SaveAtomicallyAsync(new LauncherInstallRecord(
|
||||
Path.GetFullPath(_dats),
|
||||
Path.GetFullPath(store.PreparedAssetPath),
|
||||
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||
info.Length,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion));
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
|
@ -338,6 +338,10 @@ public sealed class LauncherWindowViewModelTests
|
|||
var installer = new FakeLauncherInstaller();
|
||||
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||
|
||||
// LU1: nothing has asked for a verification yet. Startup's own
|
||||
// verification happens in the composition root, not here.
|
||||
Assert.Empty(installer.LoadExistingCalls);
|
||||
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
|
||||
Assert.Equal(installer.DetectedDirectory, viewModel.FirstRunWizardShell.DatDirectory);
|
||||
|
|
@ -433,6 +437,77 @@ public sealed class LauncherWindowViewModelTests
|
|||
Assert.True(orchestrator.ClearCalled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LU1. Ordinary startup trusts a remembered digest so the window is not
|
||||
/// held behind a multi-second hash of a ~28 GiB file; "Verify files" is
|
||||
/// the deliberate way to make it read the whole package again, so it must
|
||||
/// force the full hash rather than hit the same fast path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task VerifyFilesForcesAFullHashAndPublishesTheResult()
|
||||
{
|
||||
// Verification is gated on no session running, same as install and
|
||||
// update: a failed verification clears the install record, and doing
|
||||
// that under a live client would be incoherent.
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var record = new LauncherInstallRecord(
|
||||
"C:/dats",
|
||||
"C:/data/pak/acdream.pak",
|
||||
new string('a', 64),
|
||||
4096,
|
||||
4);
|
||||
var installer = new FakeLauncherInstaller
|
||||
{
|
||||
NextVerification = new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Verified,
|
||||
record,
|
||||
"Client content verified."),
|
||||
};
|
||||
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||
|
||||
await viewModel.VerifyContentCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal([true], installer.LoadExistingCalls);
|
||||
Assert.Same(record, orchestrator.InstalledRecord);
|
||||
Assert.Equal("Client content verified.", viewModel.OperationStatus);
|
||||
Assert.Null(viewModel.LastError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LU1. A package that fails its full verification must clear the install
|
||||
/// record — the launcher may not start a client against content it just
|
||||
/// proved is wrong — and must say why.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task VerifyFilesClearsTheInstallRecordWhenVerificationFails()
|
||||
{
|
||||
using var orchestrator = new FakeLauncherOrchestrator
|
||||
{
|
||||
Session = FakeLauncherOrchestrator.CreateSession(
|
||||
LauncherActivityState.Exited,
|
||||
"Exited cleanly."),
|
||||
};
|
||||
var installer = new FakeLauncherInstaller
|
||||
{
|
||||
NextVerification = new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Invalid,
|
||||
null,
|
||||
"The prepared package SHA-256 does not match the install record."),
|
||||
};
|
||||
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||
|
||||
await viewModel.VerifyContentCommand.ExecuteAsync();
|
||||
|
||||
Assert.Null(orchestrator.InstalledRecord);
|
||||
Assert.Contains("SHA-256", viewModel.OperationStatus, StringComparison.Ordinal);
|
||||
Assert.Contains("SHA-256", viewModel.LastError!, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static LauncherWindowViewModel CreateInitialized(
|
||||
FakeLauncherOrchestrator orchestrator,
|
||||
ILauncherInstaller? installer = null)
|
||||
|
|
@ -730,12 +805,26 @@ public sealed class LauncherWindowViewModelTests
|
|||
"The DAT directory is incomplete.",
|
||||
DatDirectoryLocator.RequiredFileNames);
|
||||
|
||||
public Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new InstallRecordVerification(
|
||||
/// <summary>LU1: what the next <see cref="LoadExistingAsync"/> returns.
|
||||
/// Defaults to "not installed", which is what every pre-existing test
|
||||
/// in this file expects.</summary>
|
||||
public InstallRecordVerification NextVerification { get; set; } =
|
||||
new(
|
||||
InstallRecordVerificationState.Missing,
|
||||
null,
|
||||
"Client content is not installed."));
|
||||
"Client content is not installed.");
|
||||
|
||||
/// <summary>LU1: the <c>forceFullVerification</c> values this fake was
|
||||
/// called with, oldest first.</summary>
|
||||
public List<bool> LoadExistingCalls { get; } = [];
|
||||
|
||||
public Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false)
|
||||
{
|
||||
LoadExistingCalls.Add(forceFullVerification);
|
||||
return Task.FromResult(NextVerification);
|
||||
}
|
||||
|
||||
public Task<LauncherInstallResult> InstallAsync(
|
||||
string datDirectory,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue