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
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue