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>
443 lines
14 KiB
C#
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."));
|
|
}
|