From 7fb02e49459bc9e07ebac45351a8656bd9947704 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Sat, 2 May 2026 19:16:14 +0200 Subject: [PATCH] =?UTF-8?q?v0.9.0=20=E2=80=94=20DL=20UX:=20cancel/restart?= =?UTF-8?q?=20confirmations,=20auto-revalidate=20license,=20fix=20progress?= =?UTF-8?q?=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/PSLauncher.App/PSLauncher.App.csproj | 6 +- .../ViewModels/MainViewModel.cs | 237 ++++++++++++++---- .../ViewModels/VersionRowViewModel.cs | 48 ++-- src/PSLauncher.App/Views/MainWindow.xaml.cs | 21 ++ 4 files changed, 241 insertions(+), 71 deletions(-) diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 7d88862..47667a7 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -15,9 +15,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.8.0 - 0.8.0.0 - 0.8.0.0 + 0.9.0 + 0.9.0.0 + 0.9.0.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 1968ce1..ea4ca33 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -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; + + /// + /// True quand un téléchargement est actif (à utiliser pour le prompt « quitter ?» de + /// la fenêtre principale). Distinct de qui couvre aussi + /// l'extraction et la vérification. + /// + public bool HasActiveDownload => _activeDownloadCts is not null + && !_activeDownloadCts.IsCancellationRequested + && _activeRow is not null + && _activeRow.State == VersionRowState.Downloading; + + /// Version actuellement en cours de DL, ou null. + 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; + /// + /// 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. + /// + 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(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(); + } + + /// + /// Discard total : annule + supprime le partial + state.json. + /// Permet de repartir de zéro depuis le bouton « ↻ Reprendre » contextuel. + /// + 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 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(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(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(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 }); } + /// + /// Re-lit le state.json depuis disque pour cette version et met à jour + /// . À 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. + /// + 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) { diff --git a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs index 8524131..ec756ef 100644 --- a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs +++ b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs @@ -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; + /// + /// 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). + /// + 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? UninstallHandler { get; set; } public Action? ShowReleaseNotesHandler { get; set; } public Action? OpenFolderHandler { get; set; } + public Action? 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); } diff --git a/src/PSLauncher.App/Views/MainWindow.xaml.cs b/src/PSLauncher.App/Views/MainWindow.xaml.cs index 69420b0..5bf59c0 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml.cs +++ b/src/PSLauncher.App/Views/MainWindow.xaml.cs @@ -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; + } + + /// + /// 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. + /// + 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; + } } ///