acdream/src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs
Erik 955c618013
All checks were successful
CI / linux-portable (push) Successful in 3m46s
CI / windows-gate (push) Successful in 5m5s
CI / release (push) Successful in 1m56s
fix: make locale-independence real, not assumed — parsing, casing, comparison
Follow-up to the retail-text fix. "Green under sv-SE" is not the same as "runs
on any locale", so this establishes the latter by running the suite under
cultures chosen to break different things, and fixing what they broke.

ar-SA found a genuine defect the Swedish runner cannot see: the resolution
parser read "1920x-1" through the ambient culture, and ar-SA's negative sign is
not ASCII '-', so the parse failed and the height silently became 0 instead of
-1. Both copies of that parser (App settings targets and the UI settings store)
now parse invariantly.

Audited every remaining culture-sensitive operation in src/ rather than fixing
only what a test happened to catch:

- Numeric Parse/TryParse with no IFormatProvider: 11 sites, all reading
  MACHINE-readable input — env vars (ACDREAM_LIGHT_DEBUG, ACDREAM_NET_DROP_*,
  streaming/quality knobs), CLI arguments, "1920x1080" settings keys, a chat
  command's price argument, and the launcher's bake thread count, which is
  handed straight to a child process command line. All pinned to
  InvariantCulture.
- ToUpper()/ToLower() with no culture: none. The Turkish-I class was already
  clean, and tr-TR confirms it.
- StartsWith/EndsWith/IndexOf(string) with no StringComparison: one —
  ChatInputParser's "@" prefix test, which is a culture-sensitive comparison
  for a single ASCII character. Now the ordinal char overload.

Verified: 13,958 tests pass identically under the machine default, sv-SE,
tr-TR, ar-SA, and de-DE. (The two launcher test assemblies are excluded from
this run only because a running acdream-launcher.exe holds its own binary; the
one launcher change here is the thread-count parse.)

Dates remain on the current culture by intent, unchanged from the previous
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:11:32 +02:00

443 lines
14 KiB
C#

using System.Globalization;
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 _isCompleted;
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());
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; }
/// <summary>LU4: OK on the "Setup complete" panel.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public bool IsCompleted
{
get => _isCompleted;
private set
{
if (SetProperty(ref _isCompleted, value))
{
OnPropertyChanged(nameof(ShowSetupForm));
NotifyCommandStates();
}
}
}
/// <summary>The form and the completion panel are mutually exclusive.</summary>
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();
}
/// <summary>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).</summary>
private void AcknowledgeCompletion()
{
IsCompleted = false;
IsOpen = false;
}
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,
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<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.";
// 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<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,
bool forceFullVerification = false) =>
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."));
}