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

@ -0,0 +1,384 @@
using System.ComponentModel;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
namespace AcDream.Launcher.ViewModels;
/// <summary>
/// Thin wizard projection over the BCL-only installer transaction. Filesystem,
/// child-process, hashing, recovery, and record publication all remain in
/// Launcher.Core; this type owns only editable fields and UI command state.
/// </summary>
public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
{
private readonly ILauncherInstaller _installer;
private readonly IUiDispatcher _dispatcher;
private readonly Action<LauncherInstallRecord> _onInstalled;
private readonly Func<bool> _canOpen;
private readonly Func<bool> _canStart;
private CancellationTokenSource? _cancellation;
private string _datDirectory = string.Empty;
private string _threadsText = Math.Max(1, Environment.ProcessorCount).ToString();
private string _status = "Choose the folder containing the retail DAT files.";
private string _validationStatus = "No DAT directory selected.";
private string _missingFiles = string.Empty;
private string? _error;
private LauncherInstallPhase _phase = LauncherInstallPhase.Idle;
private double _progressPercent;
private bool _isOpen;
private bool _isRunning;
private bool _isDatDirectoryValid;
private bool _disposed;
public FirstRunInstallerViewModel(
ILauncherInstaller installer,
IUiDispatcher dispatcher,
Action<LauncherInstallRecord> onInstalled,
Func<bool>? canOpen = null,
Func<bool>? canStart = null)
{
_installer = installer ?? throw new ArgumentNullException(nameof(installer));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_onInstalled = onInstalled ?? throw new ArgumentNullException(nameof(onInstalled));
_canOpen = canOpen ?? (() => true);
_canStart = canStart ?? (() => true);
OpenCommand = new RelayCommand(Open, () => !_disposed && _canOpen());
CloseCommand = new RelayCommand(Close, () => !IsRunning);
ValidateCommand = new RelayCommand(Validate, () => !IsRunning);
StartCommand = new AsyncRelayCommand(StartAsync, CanBeginInstall);
CancelCommand = new RelayCommand(
() => _cancellation?.Cancel(),
() => IsRunning && _cancellation is not null);
}
public string Title => "First-run setup";
public string Body =>
"Select the retail Asheron's Call DAT folder. acdream will validate "
+ "the four required files, build DataDirectory/pak/acdream.pak, and "
+ "verify its SHA-256 before enabling launch.";
public string DatDirectory
{
get => _datDirectory;
set
{
if (SetProperty(ref _datDirectory, value ?? string.Empty))
{
Validate();
}
}
}
public string ThreadsText
{
get => _threadsText;
set
{
if (SetProperty(ref _threadsText, value ?? string.Empty))
{
OnPropertyChanged(nameof(IsThreadCountValid));
OnPropertyChanged(nameof(ThreadCountValidation));
NotifyCommandStates();
}
}
}
public bool IsThreadCountValid =>
int.TryParse(ThreadsText, out int threads) && threads > 0;
public string ThreadCountValidation => IsThreadCountValid
? "Worker count is valid."
: "Threads must be a positive whole number.";
public string Status
{
get => _status;
private set => SetProperty(ref _status, value);
}
public string ValidationStatus
{
get => _validationStatus;
private set => SetProperty(ref _validationStatus, value);
}
public string MissingFiles
{
get => _missingFiles;
private set
{
if (SetProperty(ref _missingFiles, value))
{
OnPropertyChanged(nameof(HasMissingFiles));
}
}
}
public bool HasMissingFiles => MissingFiles.Length > 0;
public string? Error
{
get => _error;
private set
{
if (SetProperty(ref _error, value))
{
OnPropertyChanged(nameof(HasError));
}
}
}
public bool HasError => !string.IsNullOrWhiteSpace(Error);
public LauncherInstallPhase Phase
{
get => _phase;
private set => SetProperty(ref _phase, value);
}
public double ProgressPercent
{
get => _progressPercent;
private set => SetProperty(ref _progressPercent, value);
}
public bool IsProgressIndeterminate =>
IsRunning && ProgressPercent <= 0;
public bool CanEditInputs => !IsRunning;
public bool IsOpen
{
get => _isOpen;
private set => SetProperty(ref _isOpen, value);
}
public bool IsRunning
{
get => _isRunning;
private set
{
if (SetProperty(ref _isRunning, value))
{
OnPropertyChanged(nameof(IsProgressIndeterminate));
OnPropertyChanged(nameof(CanEditInputs));
NotifyCommandStates();
}
}
}
public bool IsDatDirectoryValid
{
get => _isDatDirectoryValid;
private set
{
if (SetProperty(ref _isDatDirectoryValid, value))
{
NotifyCommandStates();
}
}
}
public RelayCommand OpenCommand { get; }
public RelayCommand CloseCommand { get; }
public RelayCommand ValidateCommand { get; }
public AsyncRelayCommand StartCommand { get; }
public RelayCommand CancelCommand { get; }
public void SelectDatDirectory(string directory) => DatDirectory = directory;
public void ReportPickerError(string message)
{
Error = string.IsNullOrWhiteSpace(message)
? "The DAT directory picker failed."
: message;
}
public void NotifyCommandStates()
{
OpenCommand.NotifyCanExecuteChanged();
CloseCommand.NotifyCanExecuteChanged();
ValidateCommand.NotifyCanExecuteChanged();
StartCommand.NotifyCanExecuteChanged();
CancelCommand.NotifyCanExecuteChanged();
}
public void Close()
{
if (!IsRunning)
{
IsOpen = false;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_cancellation?.Cancel();
_cancellation?.Dispose();
_cancellation = null;
NotifyCommandStates();
}
private void Open()
{
if (string.IsNullOrWhiteSpace(DatDirectory))
{
IReadOnlyList<DatDirectoryValidation> candidates =
_installer.DetectDatDirectories();
DatDirectoryValidation? preferred =
candidates.FirstOrDefault(candidate => candidate.IsValid)
?? candidates.FirstOrDefault();
if (preferred is not null)
{
_datDirectory = preferred.Directory;
OnPropertyChanged(nameof(DatDirectory));
}
}
Validate();
IsOpen = true;
}
private void Validate()
{
if (IsRunning)
{
return;
}
DatDirectoryValidation validation =
_installer.ValidateDatDirectory(DatDirectory);
IsDatDirectoryValid = validation.IsValid;
ValidationStatus = validation.Message;
MissingFiles = validation.MissingFileNames.Count == 0
? string.Empty
: "Missing: " + string.Join(", ", validation.MissingFileNames);
Error = null;
NotifyCommandStates();
}
private bool CanBeginInstall() =>
!_disposed
&& IsOpen
&& !IsRunning
&& IsDatDirectoryValid
&& IsThreadCountValid
&& _canStart();
private async Task StartAsync()
{
if (!int.TryParse(ThreadsText, out int threads) || threads <= 0)
{
return;
}
using var cancellation = new CancellationTokenSource();
_cancellation = cancellation;
IsRunning = true;
Error = null;
ProgressPercent = 0;
Phase = LauncherInstallPhase.ValidatingDatFiles;
Status = "Starting installation...";
var progress = new CallbackProgress<LauncherInstallProgress>(value =>
_dispatcher.Post(() => ApplyProgress(value)));
try
{
LauncherInstallResult result = await _installer.InstallAsync(
DatDirectory,
threads,
progress,
cancellation.Token)
.ConfigureAwait(true);
_onInstalled(result.Record);
Phase = LauncherInstallPhase.Completed;
ProgressPercent = 100;
Status = "Client content installed and verified. Launch is enabled.";
}
catch (OperationCanceledException)
{
Phase = LauncherInstallPhase.Cancelled;
Status = "Installation cancelled. The previous verified install was preserved.";
}
catch (Exception ex)
{
Phase = LauncherInstallPhase.Failed;
Error = string.IsNullOrWhiteSpace(ex.Message)
? "Installation failed."
: ex.Message;
Status = "Installation failed; no new install record was published.";
}
finally
{
if (ReferenceEquals(_cancellation, cancellation))
{
_cancellation = null;
}
IsRunning = false;
}
}
private void ApplyProgress(LauncherInstallProgress progress)
{
if (_disposed)
{
return;
}
Phase = progress.Phase;
Status = progress.Status;
ProgressPercent = progress.Total > 0
? progress.Fraction * 100
: 0;
OnPropertyChanged(nameof(IsProgressIndeterminate));
}
private sealed class CallbackProgress<T>(Action<T> callback) : IProgress<T>
{
private readonly Action<T> _callback = callback
?? throw new ArgumentNullException(nameof(callback));
public void Report(T value) => _callback(value);
}
}
internal sealed class UnavailableLauncherInstaller : ILauncherInstaller
{
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() => [];
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
new(
directory ?? string.Empty,
false,
"The installer service is unavailable in this host.",
DatDirectoryLocator.RequiredFileNames);
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(new InstallRecordVerification(
InstallRecordVerificationState.Missing,
null,
"The installer service is unavailable in this host."));
public Task<LauncherInstallResult> InstallAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<LauncherInstallResult>(
new LauncherInstallException(
"The installer service is unavailable in this host."));
}