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;
+ }
}
///