using System.Globalization; using System.ComponentModel; using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Launching; namespace AcDream.Launcher.ViewModels; /// /// 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. /// public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable { private readonly ILauncherInstaller _installer; private readonly IUiDispatcher _dispatcher; private readonly Action _onInstalled; private readonly Func _canOpen; private readonly Func _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 _isCompleted; private bool _isOpen; private bool _isRunning; private bool _isDatDirectoryValid; private bool _disposed; public FirstRunInstallerViewModel( ILauncherInstaller installer, IUiDispatcher dispatcher, Action onInstalled, Func? canOpen = null, Func? 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()); AcknowledgeCompletionCommand = new RelayCommand( AcknowledgeCompletion, () => IsCompleted && !IsRunning); 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, NumberStyles.Integer, CultureInfo.InvariantCulture, 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; } /// LU4: OK on the "Setup complete" panel. public RelayCommand AcknowledgeCompletionCommand { 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; } /// /// LU4: true once first-run setup has actually succeeded — the pak was /// built, verified, and its record published. The wizard replaces its form /// with a plain "Setup complete" panel whose single OK button returns to /// the launcher, instead of leaving the user looking at a finished /// progress bar wondering whether they may close the window. /// public bool IsCompleted { get => _isCompleted; private set { if (SetProperty(ref _isCompleted, value)) { OnPropertyChanged(nameof(ShowSetupForm)); NotifyCommandStates(); } } } /// The form and the completion panel are mutually exclusive. public bool ShowSetupForm => !IsCompleted; public string CompletedTitle => "Setup complete"; public string CompletedBody => "acdream built and verified your game content. You can play now."; public void NotifyCommandStates() { OpenCommand.NotifyCanExecuteChanged(); CloseCommand.NotifyCanExecuteChanged(); ValidateCommand.NotifyCanExecuteChanged(); StartCommand.NotifyCanExecuteChanged(); CancelCommand.NotifyCanExecuteChanged(); AcknowledgeCompletionCommand.NotifyCanExecuteChanged(); } public void Close() { if (!IsRunning) { IsOpen = false; } } public void Dispose() { if (_disposed) { return; } _disposed = true; _cancellation?.Cancel(); _cancellation?.Dispose(); _cancellation = null; NotifyCommandStates(); } /// LU4: OK on the completion panel. Closes the wizard and leaves /// it ready to be reopened as an ordinary form (a user may reinstall /// content later against a different DAT folder). private void AcknowledgeCompletion() { IsCompleted = false; IsOpen = false; } private void Open() { if (string.IsNullOrWhiteSpace(DatDirectory)) { IReadOnlyList 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, NumberStyles.Integer, CultureInfo.InvariantCulture, 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(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."; // LU4: raised only here, AFTER the record is published, so the // launcher behind the dialog is already in its launch-enabled // state when the user presses OK. The cancelled and failed // branches below deliberately never reach this. IsCompleted = true; } 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(Action callback) : IProgress { private readonly Action _callback = callback ?? throw new ArgumentNullException(nameof(callback)); public void Report(T value) => _callback(value); } } internal sealed class UnavailableLauncherInstaller : ILauncherInstaller { public IReadOnlyList DetectDatDirectories() => []; public DatDirectoryValidation ValidateDatDirectory(string? directory) => new( directory ?? string.Empty, false, "The installer service is unavailable in this host.", DatDirectoryLocator.RequiredFileNames); public Task LoadExistingAsync( CancellationToken cancellationToken = default, bool forceFullVerification = false) => Task.FromResult(new InstallRecordVerification( InstallRecordVerificationState.Missing, null, "The installer service is unavailable in this host.")); public Task InstallAsync( string datDirectory, int threads, IProgress? progress = null, CancellationToken cancellationToken = default) => Task.FromException( new LauncherInstallException( "The installer service is unavailable in this host.")); }