Files
PS_Launcher/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs
j.foucher 30eceaea2c v0.20.0 — Cancel-from-red-button waits for segments + new Verifying row state
Two related UX bugs fixed.

1) Red "Annuler" button on a row : DL appeared to keep going

The button calls RestartFromZeroAsync which used to fire
_activeDownloadCts.Cancel() then sleep 200 ms then delete the .partial.
With 16 parallel segments mid-write, 200 ms is way too short — the
segments were still flushing buffers when the file got deleted, then
recreated it from their own write streams, making it look like the
download "continued".

Now we keep a reference to the in-flight install Task
(_activeInstallTask) and properly await it (with a 10 s safety timeout)
before discarding state. This guarantees all segment FileStreams are
closed and the .partial deletion is the final word.

2) Progress bar said "Downloading…" during SHA-256 verification

The footer message switched to "🔍 Vérification SHA-256 v…" but the
row's badge stayed on "⬇ Downloading…" because row.State stayed at
Downloading throughout DownloadAsync (which internally chains DL +
verify). New VersionRowState.Verifying inserted between Downloading
and Installing, applied on the first hashProgress callback. Badge now
reads "🔍 Vérification…" / "🔍 Verifying…" during the hash phase.

VersionRowViewModel.IsBusy now also includes Verifying so commands
that gate on busy stay disabled during verification.

Versions bumped to 0.20.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:34:18 +02:00

181 lines
7.4 KiB
C#

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
public enum VersionRowState
{
InstalledIdle, // Installée localement, pas d'action en cours
AvailableIdle, // Disponible côté serveur, pas installée
Downloading,
Verifying, // SHA-256 du ZIP téléchargé en cours
Installing,
Uninstalling,
}
public sealed partial class VersionRowViewModel : ObservableObject
{
public string Version { get; }
public DateTime ReleasedAt { get; }
public string ExecutablePath { get; }
public string FolderPath { get; }
public long SizeBytes { get; }
public InstalledVersion? Installed { get; }
public VersionManifest? Remote { get; }
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StateLabel))]
[NotifyPropertyChangedFor(nameof(IsBusy))]
[NotifyPropertyChangedFor(nameof(IsInstalled))]
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
private VersionRowState _state;
[ObservableProperty] private double _progressPercent;
[ObservableProperty] private string? _progressDetail;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
[NotifyPropertyChangedFor(nameof(HasResumableDownload))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(RestartFromZeroCommand))]
private long _resumableBytes;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
[NotifyPropertyChangedFor(nameof(LicenseAllowsInstall))]
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
private bool _licenseAllowsDownload = true;
public bool HasResumableDownload => ResumableBytes > 0;
public bool LicenseAllowsInstall => LicenseAllowsDownload;
/// <summary>
/// Visibilité du bouton rouge « Annuler » à côté de « ↻ Reprendre ».
/// Doit être vrai uniquement quand un partial existe ET qu'aucun DL n'est en
/// cours sur cette row (sinon le bouton « Annuler » se mélange visuellement
/// avec la barre de progression du DL actif, qui a son propre cancel).
/// </summary>
public bool ShowRestartFromZero => HasResumableDownload && State == VersionRowState.AvailableIdle;
public string InstallButtonLabel
{
get
{
if (!LicenseAllowsDownload) return Strings.ActionLicenseInsufficient;
if (!HasResumableDownload) return Strings.ActionInstall;
if (Remote is null || Remote.Download.SizeBytes <= 0) return Strings.ResumeButtonNoPercent;
var pct = (int)Math.Round((double)ResumableBytes / Remote.Download.SizeBytes * 100.0);
return Strings.ResumeButton(pct);
}
}
public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
public bool IsRemoteOnly => State is VersionRowState.AvailableIdle;
public bool IsBusy => State is VersionRowState.Downloading
or VersionRowState.Verifying
or VersionRowState.Installing
or VersionRowState.Uninstalling;
public string StateLabel => State switch
{
VersionRowState.InstalledIdle => Strings.StatusInstalled,
VersionRowState.AvailableIdle => Strings.StatusAvailable,
VersionRowState.Downloading => Strings.StatusDownloading,
VersionRowState.Verifying => Strings.StatusVerifying,
VersionRowState.Installing => Strings.StatusInstalling,
VersionRowState.Uninstalling => Strings.StatusUninstalling,
_ => string.Empty
};
public string PrimaryDate => ReleasedAt > DateTime.MinValue
? Strings.FormatDate(ReleasedAt)
: "—";
public string SizeDisplay => FormatSize(SizeBytes);
/// <summary>Constructeur ligne « installée localement » (peut aussi être présente en remote).</summary>
public static VersionRowViewModel ForInstalled(InstalledVersion installed, VersionManifest? remote)
{
return new VersionRowViewModel(
version: installed.Version,
releasedAt: remote?.ReleasedAt ?? installed.InstalledAt,
executablePath: installed.ExecutablePath,
folderPath: installed.FolderPath,
sizeBytes: installed.SizeBytes,
installed: installed,
remote: remote,
initialState: VersionRowState.InstalledIdle);
}
/// <summary>Constructeur ligne « disponible côté serveur uniquement ».</summary>
public static VersionRowViewModel ForRemote(VersionManifest remote)
{
return new VersionRowViewModel(
version: remote.Version,
releasedAt: remote.ReleasedAt,
executablePath: string.Empty,
folderPath: string.Empty,
sizeBytes: remote.Download.SizeBytes,
installed: null,
remote: remote,
initialState: VersionRowState.AvailableIdle);
}
private VersionRowViewModel(
string version, DateTime releasedAt, string executablePath, string folderPath,
long sizeBytes, InstalledVersion? installed, VersionManifest? remote,
VersionRowState initialState)
{
Version = version;
ReleasedAt = releasedAt;
ExecutablePath = executablePath;
FolderPath = folderPath;
SizeBytes = sizeBytes;
Installed = installed;
Remote = remote;
_state = initialState;
}
// Les commandes sont câblées par le MainViewModel après instanciation
// (les actions ont besoin de services).
public Action<VersionRowViewModel>? LaunchHandler { get; set; }
public Action<VersionRowViewModel>? InstallHandler { get; set; }
public Action<VersionRowViewModel>? UninstallHandler { get; set; }
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
[RelayCommand(CanExecute = nameof(CanLaunch))]
private void Launch() => LaunchHandler?.Invoke(this);
private bool CanLaunch() => State == VersionRowState.InstalledIdle;
[RelayCommand(CanExecute = nameof(CanInstall))]
private void Install() => InstallHandler?.Invoke(this);
private bool CanInstall() => State == VersionRowState.AvailableIdle && LicenseAllowsDownload;
[RelayCommand(CanExecute = nameof(CanUninstall))]
private void Uninstall() => UninstallHandler?.Invoke(this);
private bool CanUninstall() => State == VersionRowState.InstalledIdle;
[RelayCommand]
private void ShowReleaseNotes() => ShowReleaseNotesHandler?.Invoke(this);
[RelayCommand]
private void OpenFolder() => OpenFolderHandler?.Invoke(this);
[RelayCommand(CanExecute = nameof(CanRestartFromZero))]
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
private bool CanRestartFromZero() => HasResumableDownload;
private static string FormatSize(long bytes)
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
}