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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user