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>
181 lines
7.4 KiB
C#
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);
|
|
}
|