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>
This commit is contained in:
2026-05-03 18:34:18 +02:00
parent 3c8438f3fe
commit 30eceaea2c
6 changed files with 43 additions and 11 deletions

View File

@@ -15,9 +15,9 @@
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.19.0</Version>
<AssemblyVersion>0.19.0.0</AssemblyVersion>
<FileVersion>0.19.0.0</FileVersion>
<Version>0.20.0</Version>
<AssemblyVersion>0.20.0.0</AssemblyVersion>
<FileVersion>0.20.0.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -53,6 +53,13 @@ public sealed partial class MainViewModel : ObservableObject
private RemoteManifest? _lastManifest;
private CancellationTokenSource? _activeDownloadCts;
private VersionRowViewModel? _activeRow;
/// <summary>
/// Référence au Task de l'install/DL en cours, exposée aux commandes externes
/// (ex. RestartFromZero) qui ont besoin d'attendre la fin réelle des écritures
/// disque avant de supprimer le partial. Sinon les segments continuent à écrire
/// pendant 1-2 sec après le Cancel() et recréent le fichier qu'on vient de delete.
/// </summary>
private Task? _activeInstallTask;
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
@@ -333,7 +340,7 @@ public sealed partial class MainViewModel : ObservableObject
private void WireRowHandlers(VersionRowViewModel row)
{
row.LaunchHandler = LaunchVersion;
row.InstallHandler = r => _ = InstallVersionAsync(r);
row.InstallHandler = r => _activeInstallTask = InstallVersionAsync(r);
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
row.OpenFolderHandler = OpenVersionFolder;
@@ -545,12 +552,26 @@ public sealed partial class MainViewModel : ObservableObject
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
// Cancel le DL actif si c'est cette row
// Cancel le DL actif si c'est cette row, puis attend la propagation complète
// (les 16 segments doivent chacun stopper leurs écritures et fermer leur
// FileStream sinon DiscardResumableState échouera/sera réécrasé).
if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
{
_activeDownloadCts.Cancel();
// Attend la fin de la tâche en cours pour libérer les handles fichier
try { await Task.Delay(200); } catch { }
// Attend que InstallVersionAsync termine son catch OperationCanceledException
// — y compris le flush + state save + libération des FileStream segment.
// Timeout de sécurité 10s au cas où une connexion HTTPS bloque sur close.
if (_activeInstallTask is { } running && !running.IsCompleted)
{
StatusMessage = "Annulation en cours…";
try
{
var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10)));
if (!ReferenceEquals(awaitTask, running))
_logger.LogWarning("Install task did not finish cancelling within 10s, proceeding anyway");
}
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during restart"); }
}
}
_downloadManager.DiscardResumableState(row.Version);
@@ -682,9 +703,13 @@ public sealed partial class MainViewModel : ObservableObject
row.ProgressPercent = 0;
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
// Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
// On bascule le StatusMessage à ce moment-là pour que l'utilisateur sache ce qui se passe.
// On bascule row.State sur Verifying à la première remontée, ce qui change
// le badge de la row de "⬇ Téléchargement…" vers "🔍 Vérification…" — les
// deux phases sont distinctes côté UX bien que `DownloadAsync` les enchaîne.
var hashProgress = new Progress<double>(pct =>
{
if (row.State == VersionRowState.Downloading)
row.State = VersionRowState.Verifying;
var percent = (int)Math.Round(pct * 100.0);
ProgressPercent = percent;
row.ProgressPercent = percent;
@@ -879,6 +904,7 @@ public sealed partial class MainViewModel : ObservableObject
_activeDownloadCts?.Dispose();
_activeDownloadCts = null;
_activeRow = null;
_activeInstallTask = null;
IsBusy = false;
}
}

View File

@@ -10,6 +10,7 @@ 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,
}
@@ -77,13 +78,17 @@ public sealed partial class VersionRowViewModel : ObservableObject
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.Installing or VersionRowState.Uninstalling;
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

View File

@@ -95,6 +95,7 @@ public static class Strings
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…");
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…");
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
// ==================== ACTIONS ====================
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");

View File

@@ -8,7 +8,7 @@
<LangVersion>latest</LangVersion>
<AssemblyName>PS_Launcher.Updater</AssemblyName>
<RootNamespace>PSLauncher.Updater</RootNamespace>
<Version>0.19.0</Version>
<Version>0.20.0</Version>
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
<Company>ASTERION VR</Company>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>