feat(launcher): add verified first-run installer

This commit is contained in:
Erik 2026-08-14 20:06:37 +02:00
parent 60f627998c
commit ff6ebb6a6a
28 changed files with 3259 additions and 125 deletions

View file

@ -1,6 +1,7 @@
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
@ -32,7 +33,7 @@ public sealed class LauncherWindowViewModelTests
Assert.True(session.IsActive);
Assert.True(viewModel.IsFirstRunRequired);
Assert.Contains("LA9", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
Assert.Contains("LA10", viewModel.UpdatePromptShell.Body, StringComparison.Ordinal);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
@ -325,6 +326,92 @@ public sealed class LauncherWindowViewModelTests
Assert.False(viewModel.EditorDialog.IsOpen);
}
[Fact]
public async Task FirstRunWizardAutoDetectsValidatesAndPublishesVerifiedInstall()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
using var viewModel = CreateInitialized(orchestrator, installer);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.Equal(installer.DetectedDirectory, viewModel.FirstRunWizardShell.DatDirectory);
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
Assert.True(viewModel.FirstRunWizardShell.StartCommand.CanExecute(null));
viewModel.FirstRunWizardShell.SelectDatDirectory("incomplete-manual-path");
Assert.False(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
viewModel.FirstRunWizardShell.SelectDatDirectory(installer.DetectedDirectory);
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
viewModel.FirstRunWizardShell.ThreadsText = "3";
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.Equal((installer.DetectedDirectory, 3), installer.InstallRequest);
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.False(viewModel.IsFirstRunRequired);
Assert.Equal(LauncherInstallPhase.Completed, viewModel.FirstRunWizardShell.Phase);
Assert.Equal(100, viewModel.FirstRunWizardShell.ProgressPercent);
Assert.False(viewModel.FirstRunWizardShell.HasError);
}
[Fact]
public async Task FirstRunWizardCancellationAndFailureRemainVisibleAndPublishNothing()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var entered = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var installer = new FakeLauncherInstaller
{
InstallHandler = async (_, _, progress, cancellationToken) =>
{
progress?.Report(new LauncherInstallProgress(
LauncherInstallPhase.BakingMeshes,
"Baking mesh assets.",
1,
10));
entered.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("unreachable");
},
};
using var viewModel = CreateInitialized(orchestrator, installer);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Task install = viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.True(viewModel.FirstRunWizardShell.CancelCommand.CanExecute(null));
Assert.False(viewModel.FirstRunWizardShell.CanEditInputs);
viewModel.FirstRunWizardShell.CancelCommand.Execute(null);
await install;
Assert.Equal(LauncherInstallPhase.Cancelled, viewModel.FirstRunWizardShell.Phase);
Assert.Null(orchestrator.InstalledRecord);
Assert.False(viewModel.FirstRunWizardShell.HasError);
installer.InstallHandler = (_, _, _, _) =>
Task.FromException<LauncherInstallResult>(
new LauncherInstallException("fixture bake failed"));
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.Equal(LauncherInstallPhase.Failed, viewModel.FirstRunWizardShell.Phase);
Assert.Contains(
"fixture bake failed",
viewModel.FirstRunWizardShell.Error ?? string.Empty,
StringComparison.Ordinal);
Assert.Null(orchestrator.InstalledRecord);
}
[Fact]
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
{
@ -345,11 +432,13 @@ public sealed class LauncherWindowViewModelTests
}
private static LauncherWindowViewModel CreateInitialized(
FakeLauncherOrchestrator orchestrator)
FakeLauncherOrchestrator orchestrator,
ILauncherInstaller? installer = null)
{
var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher());
new ImmediateUiDispatcher(),
installer);
viewModel.Initialize();
return viewModel;
}
@ -425,14 +514,18 @@ public sealed class LauncherWindowViewModelTests
public string? StoppedSessionId { get; private set; }
public LauncherInstallRecord? InstalledRecord { get; private set; }
public void LoadProfiles() => LoadCalled = true;
public LauncherStateSnapshot GetSnapshot() => new(
[CreateServerSnapshot()],
[Session],
Platform,
IsInstallationReady: false,
InstallationStatus: "No installed client is configured.");
IsInstallationReady: InstalledRecord is not null,
InstallationStatus: InstalledRecord is null
? "No installed client is configured."
: "Client content verified.");
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
Platform.ForLaunchMode(mode);
@ -448,6 +541,8 @@ public sealed class LauncherWindowViewModelTests
public void SetInstallRecord(LauncherInstallRecord? installRecord)
{
InstalledRecord = installRecord;
StateChanged?.Invoke(this, EventArgs.Empty);
}
public void AddServer(string name, string host, int port) =>
@ -589,4 +684,83 @@ public sealed class LauncherWindowViewModelTests
ActivityStatus: "Connected."),
]);
}
private sealed class FakeLauncherInstaller : ILauncherInstaller
{
public string DetectedDirectory { get; } = Path.GetFullPath("retail-dats");
public LauncherInstallRecord Record { get; }
public (string DatDirectory, int Threads)? InstallRequest { get; private set; }
public Func<
string,
int,
IProgress<LauncherInstallProgress>?,
CancellationToken,
Task<LauncherInstallResult>>? InstallHandler { get; set; }
public FakeLauncherInstaller()
{
Record = new LauncherInstallRecord(
DetectedDirectory,
Path.GetFullPath("data/pak/acdream.pak"),
new string('a', 64),
123,
LauncherInstallRecordStore.CurrentBakeToolVersion);
}
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() =>
[
ValidateDatDirectory(DetectedDirectory),
];
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
string.Equals(directory, DetectedDirectory, StringComparison.Ordinal)
? new DatDirectoryValidation(
DetectedDirectory,
true,
"All four required retail DAT files were found.",
[])
: new DatDirectoryValidation(
directory ?? string.Empty,
false,
"The DAT directory is incomplete.",
DatDirectoryLocator.RequiredFileNames);
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(new InstallRecordVerification(
InstallRecordVerificationState.Missing,
null,
"Client content is not installed."));
public Task<LauncherInstallResult> InstallAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
InstallRequest = (datDirectory, threads);
if (InstallHandler is not null)
{
return InstallHandler(
datDirectory,
threads,
progress,
cancellationToken);
}
progress?.Report(new LauncherInstallProgress(
LauncherInstallPhase.BakingMeshes,
"Baking mesh assets.",
5,
10));
progress?.Report(new LauncherInstallProgress(
LauncherInstallPhase.VerifyingPackage,
"Verifying package."));
return Task.FromResult(new LauncherInstallResult(Record));
}
}
}