v0.9.0 — DL UX: cancel/restart confirmations, auto-revalidate license, fix progress refresh

UX
- Red "Annuler" button next to "↻ Reprendre" on rows with a partial download.
  Confirmation MessageBox before discarding the .partial + state.json.
- "Recommencer depuis zéro" entry in the row's "..." context menu, visible
  only when a partial exists.
- Cancel button visibility tied to a new ShowRestartFromZero property
  (HasResumableDownload && State == AvailableIdle): hides during active DL
  to avoid colliding with the inline cancel.
- ResumableBytes refreshed from state.json after cancel/error so the
  "Reprendre (X%)" label reflects the actual percentage reached, not the
  stale value from launcher startup.
- MainWindow.Closing prompts for confirmation if a download is in progress
  (HasActiveDownload), so an accidental ✕ doesn't waste a 14 GB transfer.
- "🔧 Préparation du téléchargement v…" status set immediately on click and
  kept visible during HEAD/SetLength so the user knows something's happening
  before the first byte. ProgressDetail wired with NotifyPropertyChangedFor
  (FooterText) so download speed shows during DL and not just at install
  transition.

Auto-revalidation
- CheckForUpdatesAsync now runs RefreshLicenseFromServerAsync first: every
  startup auto-check + every manual "Vérifier les MAJ" click pulls the
  latest license state from /api/license/validate. Date changes / revocations
  done in the backoffice propagate to clients without waiting for the 100-day
  cache to expire. Silent fallback to cached state if offline.

Version bumped to 0.9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 19:16:14 +02:00
parent 07d58a8854
commit 7fb02e4945
4 changed files with 241 additions and 71 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.8.0</Version>
<AssemblyVersion>0.8.0.0</AssemblyVersion>
<FileVersion>0.8.0.0</FileVersion>
<Version>0.9.0</Version>
<AssemblyVersion>0.9.0.0</AssemblyVersion>
<FileVersion>0.9.0.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -65,24 +65,39 @@ public sealed partial class MainViewModel : ObservableObject
private bool _isBusy;
[ObservableProperty] private double _progressPercent;
[ObservableProperty] private string? _progressDetail;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FooterText))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
private string? _progressDetail;
/// <summary>
/// True quand un téléchargement est actif (à utiliser pour le prompt « quitter ?» de
/// la fenêtre principale). Distinct de <see cref="IsBusy"/> qui couvre aussi
/// l'extraction et la vérification.
/// </summary>
public bool HasActiveDownload => _activeDownloadCts is not null
&& !_activeDownloadCts.IsCancellationRequested
&& _activeRow is not null
&& _activeRow.State == VersionRowState.Downloading;
/// <summary>Version actuellement en cours de DL, ou null.</summary>
public string? ActiveDownloadVersion => _activeRow?.Version;
public string LicenseSummary
{
get
{
if (_license is null) return "Aucune license";
if (_license is null) return Strings.LicenseSummaryNone;
if (_license.Status == "valid")
{
var until = _license.DownloadEntitlementUntil;
if (until is { } u && (u - DateTime.UtcNow).TotalDays < 30)
return $"{_license.OwnerName} • exp. {u:dd/MM/yyyy}";
return $"{_license.OwnerName} • exp. {until:dd/MM/yyyy}";
return Strings.LicenseSummaryValid(_license.OwnerName ?? "—",
Strings.FormatDate(_license.DownloadEntitlementUntil));
}
if (_license.Status == "expired")
return $"Expirée le {_license.DownloadEntitlementUntil:dd/MM/yyyy}";
return Strings.LicenseSummaryExpired(Strings.FormatDate(_license.DownloadEntitlementUntil));
if (_license.Status == "revoked")
return "Révoquée";
return Strings.LicenseSummaryRevoked;
return _license.Status;
}
}
@@ -133,11 +148,7 @@ public sealed partial class MainViewModel : ObservableObject
OnPropertyChanged(nameof(LicenseSeverity));
}
public string EmptyHint =>
"Aucune version locale ni distante.\n" +
"Clique « Vérifier les mises à jour » pour récupérer le catalogue depuis le serveur, " +
"ou place un dossier « PROSERVE v{X.Y.Z} » contenant PROSERVE_UE_5_5.exe dans :\n" +
_config.InstallRoot;
public string EmptyHint => Strings.EmptyHint(_config.InstallRoot);
public Visibility EmptyHintVisibility =>
FeaturedVersion is null && OtherVersions.Count == 0
@@ -267,19 +278,27 @@ public sealed partial class MainViewModel : ObservableObject
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
row.OpenFolderHandler = OpenVersionFolder;
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
}
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
private async Task CheckForUpdatesAsync()
{
IsBusy = true;
StatusMessage = "Vérification des mises à jour…";
StatusMessage = Strings.StatusCheckingUpdates;
try
{
// Re-validation de la license en parallèle du fetch manifest. Permet
// au client de refléter sans délai les changements admin (modification
// de date, révocation, etc.) sans attendre l'expiration du cache offline
// de 100 jours. Si l'utilisateur est offline ou si l'API ne répond pas,
// on garde le cache silencieusement (pas de crash).
await RefreshLicenseFromServerAsync(CancellationToken.None);
var result = await _updateChecker.CheckAsync(CancellationToken.None);
if (result.Error is not null)
{
StatusMessage = $"Erreur : {result.Error}";
StatusMessage = Strings.StatusError(result.Error);
return;
}
@@ -294,16 +313,16 @@ public sealed partial class MainViewModel : ObservableObject
}
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
StatusMessage = $"Nouvelle version disponible : v{result.LatestRemote.Version}";
StatusMessage = Strings.StatusNewAvailable(result.LatestRemote.Version);
else if (result.LatestRemote is not null)
StatusMessage = $"À jour (v{result.LatestRemote.Version})";
StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version);
else
StatusMessage = "Aucune version distante trouvée";
StatusMessage = Strings.StatusNoRemote;
}
catch (Exception ex)
{
_logger.LogError(ex, "Update check failed");
StatusMessage = $"Erreur : {ex.Message}";
StatusMessage = Strings.StatusError(ex.Message);
}
finally
{
@@ -312,6 +331,31 @@ public sealed partial class MainViewModel : ObservableObject
}
private bool CanCheckUpdates() => !IsBusy;
/// <summary>
/// Tente une re-validation online de la license. Met à jour le cache local
/// avec les valeurs fraîches du serveur (date d'expiration, statut révoqué, etc.).
/// Silencieux en cas d'échec réseau pour ne pas casser le check des MAJ ; le
/// cache offline reste utilisable.
/// </summary>
private async Task RefreshLicenseFromServerAsync(CancellationToken ct)
{
var key = _licenseService.GetDecryptedKey();
if (string.IsNullOrEmpty(key)) return;
try
{
var resp = await _licenseService.ValidateAsync(key, ct);
_licenseService.SaveCached(key, resp);
_license = _licenseService.GetCached();
NotifyLicenseChanged();
_logger.LogInformation("License revalidated from server (status={Status}, until={Until})",
resp.Status, resp.DownloadEntitlementUntil);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "License revalidation failed (offline or server error). Keeping cached state.");
}
}
private async void PromptLauncherUpdate(LauncherInfo launcher)
{
try
@@ -324,15 +368,15 @@ public sealed partial class MainViewModel : ObservableObject
if (!dialog.UpdateRequested) return;
IsBusy = true;
StatusMessage = "Téléchargement du nouveau launcher…";
StatusMessage = Strings.StatusDownloadingLauncher;
var progress = new Progress<DownloadProgress>(p =>
{
if (p.TotalBytes > 0) ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
ProgressDetail = $"⬇ Launcher v{launcher.Version} : {FormatSize(p.BytesDownloaded)} / {FormatSize(p.TotalBytes)}";
ProgressDetail = Strings.ProgressLauncherDl(launcher.Version, FormatSize(p.BytesDownloaded), FormatSize(p.TotalBytes));
});
await _selfUpdater.DownloadAndApplyAsync(launcher, progress, CancellationToken.None);
StatusMessage = "Mise à jour du launcher en cours… le launcher va se relancer.";
StatusMessage = Strings.StatusLauncherUpdating;
await Task.Delay(800); // laisse le temps au toast / message de s'afficher
Application.Current.Shutdown();
}
@@ -341,7 +385,7 @@ public sealed partial class MainViewModel : ObservableObject
_logger.LogError(ex, "Self-update failed");
MessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur self-update : {ex.Message}";
StatusMessage = Strings.StatusSelfUpdateError(ex.Message);
IsBusy = false;
}
}
@@ -416,7 +460,49 @@ public sealed partial class MainViewModel : ObservableObject
}
[RelayCommand]
private void CancelDownload() => _activeDownloadCts?.Cancel();
private void CancelDownload()
{
if (_activeDownloadCts is null || _activeRow is null) return;
var confirm = MessageBox.Show(
Strings.MsgCancelDownloadConfirm(_activeRow.Version),
Strings.MsgBoxCancelDl,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
_activeDownloadCts.Cancel();
}
/// <summary>
/// Discard total : annule + supprime le partial + state.json.
/// Permet de repartir de zéro depuis le bouton « ↻ Reprendre » contextuel.
/// </summary>
private async Task RestartFromZeroAsync(VersionRowViewModel row)
{
// Si un DL est actif sur cette même row, on doit d'abord l'annuler proprement.
var st = _downloadManager.GetResumableState(row.Version);
var sizeStr = FormatSize(st?.DownloadedBytes ?? 0);
var confirm = MessageBox.Show(
Strings.MsgRestartDownloadConfirm(row.Version, sizeStr),
Strings.MsgBoxRestartDl,
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
// Cancel le DL actif si c'est cette row
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 { }
}
_downloadManager.DiscardResumableState(row.Version);
row.ResumableBytes = 0;
row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null;
row.ProgressPercent = 0;
StatusMessage = null;
ProgressDetail = null;
ProgressPercent = 0;
}
// ------------ Per-row actions ------------
@@ -488,14 +574,14 @@ public sealed partial class MainViewModel : ObservableObject
if (!isResume)
{
// 1) Récupère release notes (best effort)
string notes = "_Aucune release note fournie._";
string notes = Strings.ReleaseNotesNone;
if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
{
try
{
notes = await _manifestService.FetchReleaseNotesAsync(row.Remote.ReleaseNotesUrl, ct);
}
catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = "_Release notes indisponibles._"; }
catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = Strings.ReleaseNotesUnavailable; }
}
// 2) Dialog de confirmation avec release notes
@@ -511,14 +597,49 @@ public sealed partial class MainViewModel : ObservableObject
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
var urlString = signed ?? row.Remote.Download.Url;
var url = new Uri(urlString);
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256);
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
// reçoit 403/410 (URL HMAC expirée mid-DL). Permet aux DLs très longs sur
// connexion lente (>= 6 h, le TTL serveur) de continuer transparemment.
// Si la license a été révoquée entre-temps, le callback renvoie null et le
// DL fail proprement.
async Task<Uri?> RefreshUrlAsync(CancellationToken c)
{
var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c);
return fresh is null ? null : new Uri(fresh);
}
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256)
{
HashAlgorithm = row.Remote.Download.HashAlgorithm,
RefreshUrlAsync = RefreshUrlAsync,
};
// Status « Préparation… » immédiat : entre le clic et la première réponse
// HEAD du serveur + pré-allocation du fichier 14 Go, il peut s'écouler
// 5-30 s silencieuses. On rassure l'utilisateur tout de suite ; le message
// sera remplacé automatiquement par « ⬇ v1.4.6 : … » dès le premier byte.
StatusMessage = Strings.StatusPreparingDownload(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
row.ProgressDetail = null;
row.ProgressPercent = 0;
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
StatusMessage = $"Téléchargement v{row.Version}…";
var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, ct);
// 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.
var hashProgress = new Progress<double>(pct =>
{
var percent = (int)Math.Round(pct * 100.0);
ProgressPercent = percent;
row.ProgressPercent = percent;
ProgressDetail = Strings.ProgressVerifying(row.Version, percent);
row.ProgressDetail = ProgressDetail;
});
// NB: pas de StatusMessage = "Téléchargement…" ici — on garde « Préparation… »
// visible pendant la phase HEAD/SetLength, puis le footer passe directement
// au format progress « ⬇ v1.4.6 : X / 14 Go • Y Mo/s » dès le premier rapport.
var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, hashProgress, ct);
// 4) Install
row.State = VersionRowState.Installing;
StatusMessage = $"Installation v{row.Version}…";
StatusMessage = Strings.StatusInstallingVersion(row.Version);
var folderName = row.Remote.GetInstallFolderName();
var target = Path.Combine(_config.InstallRoot, folderName);
var installProgress = new Progress<InstallProgress>(ip =>
@@ -527,27 +648,31 @@ public sealed partial class MainViewModel : ObservableObject
var pct = (double)ip.BytesDone / ip.BytesTotal * 100.0;
ProgressPercent = pct;
row.ProgressPercent = pct;
ProgressDetail = $"Extraction v{row.Version} : {ip.EntriesDone}/{ip.EntriesTotal} fichiers ({FormatSize(ip.BytesDone)} / {FormatSize(ip.BytesTotal)})";
ProgressDetail = Strings.ProgressExtraction(row.Version, ip.EntriesDone, ip.EntriesTotal, FormatSize(ip.BytesDone), FormatSize(ip.BytesTotal));
row.ProgressDetail = ProgressDetail;
});
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
try { File.Delete(zipPath); } catch { /* non critique */ }
StatusMessage = $"v{row.Version} installée avec succès";
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
RebuildList();
_toastService.ShowSuccess(
"Installation terminée",
$"PROSERVE v{row.Version} est prête à être lancée.");
Strings.ToastInstallSuccessTitle,
Strings.ToastInstallSuccessBody(row.Version));
}
catch (OperationCanceledException)
{
StatusMessage = "Téléchargement annulé";
StatusMessage = Strings.StatusDownloadCancelled;
row.State = VersionRowState.AvailableIdle;
row.ProgressDetail = null;
row.ProgressPercent = 0;
// Rafraîchit le pourcentage de reprise depuis le state.json sauvegardé
// au moment du cancel. Sinon le bouton « Reprendre (X%) » garderait la
// valeur d'avant le DL (souvent 0% ou l'ancien %).
RefreshResumableBytes(row);
}
catch (Exception ex)
{
@@ -558,8 +683,11 @@ public sealed partial class MainViewModel : ObservableObject
MessageBox.Show(
Strings.MsgInstallFailed(ex.Message),
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur : {ex.Message}";
_toastService.ShowError($"Échec v{row.Version}", ex.Message);
StatusMessage = Strings.StatusError(ex.Message);
_toastService.ShowError(Strings.ToastInstallErrorTitle(row.Version), ex.Message);
// Idem : si une erreur réseau interrompt le DL en cours de route, le
// partial est plus gros qu'avant — il faut refléter le nouveau %.
RefreshResumableBytes(row);
}
finally
{
@@ -586,11 +714,11 @@ public sealed partial class MainViewModel : ObservableObject
IsBusy = true;
row.State = VersionRowState.Uninstalling;
StatusMessage = $"Suppression v{row.Version}…";
StatusMessage = Strings.StatusUninstallingVersion(row.Version);
try
{
await _registry.DeleteAsync(row.Version, null, CancellationToken.None);
StatusMessage = $"v{row.Version} supprimée";
StatusMessage = Strings.StatusUninstalledVersion(row.Version);
RebuildList();
}
catch (Exception ex)
@@ -599,7 +727,7 @@ public sealed partial class MainViewModel : ObservableObject
row.State = VersionRowState.InstalledIdle;
MessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError,
MessageBoxButton.OK, MessageBoxImage.Error);
StatusMessage = $"Erreur : {ex.Message}";
StatusMessage = Strings.StatusError(ex.Message);
}
finally
{
@@ -641,6 +769,25 @@ public sealed partial class MainViewModel : ObservableObject
});
}
/// <summary>
/// Re-lit le state.json depuis disque pour cette version et met à jour
/// <see cref="VersionRowViewModel.ResumableBytes"/>. À appeler après un cancel ou
/// une exception en milieu de DL pour que le bouton « Reprendre (X%) » reflète
/// le pourcentage réellement atteint et pas la valeur d'avant le DL.
/// </summary>
private void RefreshResumableBytes(VersionRowViewModel row)
{
try
{
var st = _downloadManager.GetResumableState(row.Version);
row.ResumableBytes = st?.DownloadedBytes ?? 0;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to refresh resumable bytes for {Version}", row.Version);
}
}
private void UpdateDlProgress(VersionRowViewModel row, DownloadProgress p)
{
if (p.TotalBytes > 0)
@@ -661,15 +808,7 @@ public sealed partial class MainViewModel : ObservableObject
row.ProgressDetail = s;
}
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "0 o";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double v = bytes;
int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
private static string FormatSize(long bytes) => Strings.FormatSize(bytes);
private static string FormatEta(TimeSpan eta)
{

View File

@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
@@ -29,6 +30,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(IsBusy))]
[NotifyPropertyChangedFor(nameof(IsInstalled))]
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
@@ -40,6 +42,8 @@ public sealed partial class VersionRowViewModel : ObservableObject
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
[NotifyPropertyChangedFor(nameof(HasResumableDownload))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(RestartFromZeroCommand))]
private long _resumableBytes;
[ObservableProperty]
@@ -51,15 +55,23 @@ public sealed partial class VersionRowViewModel : ObservableObject
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 "🔒 License insuffisante";
if (!HasResumableDownload) return "⬇ Installer";
if (Remote is null || Remote.Download.SizeBytes <= 0) return "↻ Reprendre";
var pct = (double)ResumableBytes / Remote.Download.SizeBytes * 100.0;
return $"↻ Reprendre ({pct:0}%)";
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);
}
}
@@ -69,16 +81,16 @@ public sealed partial class VersionRowViewModel : ObservableObject
public string StateLabel => State switch
{
VersionRowState.InstalledIdle => "● Installée",
VersionRowState.AvailableIdle => "○ Disponible",
VersionRowState.Downloading => "⬇ Téléchargement…",
VersionRowState.Installing => "📦 Installation…",
VersionRowState.Uninstalling => "🗑 Suppression…",
VersionRowState.InstalledIdle => Strings.StatusInstalled,
VersionRowState.AvailableIdle => Strings.StatusAvailable,
VersionRowState.Downloading => Strings.StatusDownloading,
VersionRowState.Installing => Strings.StatusInstalling,
VersionRowState.Uninstalling => Strings.StatusUninstalling,
_ => string.Empty
};
public string PrimaryDate => ReleasedAt > DateTime.MinValue
? ReleasedAt.ToLocalTime().ToString("dd/MM/yyyy")
? Strings.FormatDate(ReleasedAt)
: "—";
public string SizeDisplay => FormatSize(SizeBytes);
@@ -134,6 +146,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
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);
@@ -153,13 +166,10 @@ public sealed partial class VersionRowViewModel : ObservableObject
[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)
{
if (bytes <= 0) return "—";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double v = bytes;
int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
}

View File

@@ -3,6 +3,8 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Interop;
using PSLauncher.App.ViewModels;
using PSLauncher.Core.Localization;
namespace PSLauncher.App.Views;
@@ -27,6 +29,25 @@ public partial class MainWindow : Window
{
InitializeComponent();
SourceInitialized += MainWindow_SourceInitialized;
Closing += MainWindow_Closing;
}
/// <summary>
/// Si un DL est en cours, demande confirmation avant de fermer le launcher.
/// La progression est de toute façon préservée pour reprise au prochain lancement,
/// mais on évite à l'utilisateur de cliquer ✕ par accident en plein milieu d'un 14 Go.
/// </summary>
private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (DataContext is MainViewModel vm && vm.HasActiveDownload)
{
var version = vm.ActiveDownloadVersion ?? "?";
var result = MessageBox.Show(
Strings.MsgQuitWhileDownloadingConfirm(version),
Strings.MsgBoxQuit,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (result != MessageBoxResult.Yes) e.Cancel = true;
}
}
/// <summary>