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:
@@ -15,9 +15,9 @@
|
|||||||
<Product>PROSERVE Launcher</Product>
|
<Product>PROSERVE Launcher</Product>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>0.8.0</Version>
|
<Version>0.9.0</Version>
|
||||||
<AssemblyVersion>0.8.0.0</AssemblyVersion>
|
<AssemblyVersion>0.9.0.0</AssemblyVersion>
|
||||||
<FileVersion>0.8.0.0</FileVersion>
|
<FileVersion>0.9.0.0</FileVersion>
|
||||||
|
|
||||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|||||||
@@ -65,24 +65,39 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private bool _isBusy;
|
private bool _isBusy;
|
||||||
|
|
||||||
[ObservableProperty] private double _progressPercent;
|
[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
|
public string LicenseSummary
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (_license is null) return "Aucune license";
|
if (_license is null) return Strings.LicenseSummaryNone;
|
||||||
if (_license.Status == "valid")
|
if (_license.Status == "valid")
|
||||||
{
|
{
|
||||||
var until = _license.DownloadEntitlementUntil;
|
return Strings.LicenseSummaryValid(_license.OwnerName ?? "—",
|
||||||
if (until is { } u && (u - DateTime.UtcNow).TotalDays < 30)
|
Strings.FormatDate(_license.DownloadEntitlementUntil));
|
||||||
return $"{_license.OwnerName} • exp. {u:dd/MM/yyyy}";
|
|
||||||
return $"{_license.OwnerName} • exp. {until:dd/MM/yyyy}";
|
|
||||||
}
|
}
|
||||||
if (_license.Status == "expired")
|
if (_license.Status == "expired")
|
||||||
return $"Expirée le {_license.DownloadEntitlementUntil:dd/MM/yyyy}";
|
return Strings.LicenseSummaryExpired(Strings.FormatDate(_license.DownloadEntitlementUntil));
|
||||||
if (_license.Status == "revoked")
|
if (_license.Status == "revoked")
|
||||||
return "Révoquée";
|
return Strings.LicenseSummaryRevoked;
|
||||||
return _license.Status;
|
return _license.Status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,11 +148,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(LicenseSeverity));
|
OnPropertyChanged(nameof(LicenseSeverity));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string EmptyHint =>
|
public string EmptyHint => Strings.EmptyHint(_config.InstallRoot);
|
||||||
"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 Visibility EmptyHintVisibility =>
|
public Visibility EmptyHintVisibility =>
|
||||||
FeaturedVersion is null && OtherVersions.Count == 0
|
FeaturedVersion is null && OtherVersions.Count == 0
|
||||||
@@ -267,19 +278,27 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
|
row.UninstallHandler = r => _ = UninstallVersionAsync(r);
|
||||||
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
||||||
row.OpenFolderHandler = OpenVersionFolder;
|
row.OpenFolderHandler = OpenVersionFolder;
|
||||||
|
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
|
[RelayCommand(CanExecute = nameof(CanCheckUpdates))]
|
||||||
private async Task CheckForUpdatesAsync()
|
private async Task CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
StatusMessage = "Vérification des mises à jour…";
|
StatusMessage = Strings.StatusCheckingUpdates;
|
||||||
try
|
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);
|
var result = await _updateChecker.CheckAsync(CancellationToken.None);
|
||||||
if (result.Error is not null)
|
if (result.Error is not null)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Erreur : {result.Error}";
|
StatusMessage = Strings.StatusError(result.Error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,16 +313,16 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null)
|
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)
|
else if (result.LatestRemote is not null)
|
||||||
StatusMessage = $"À jour (v{result.LatestRemote.Version})";
|
StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version);
|
||||||
else
|
else
|
||||||
StatusMessage = "Aucune version distante trouvée";
|
StatusMessage = Strings.StatusNoRemote;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Update check failed");
|
_logger.LogError(ex, "Update check failed");
|
||||||
StatusMessage = $"Erreur : {ex.Message}";
|
StatusMessage = Strings.StatusError(ex.Message);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -312,6 +331,31 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
private bool CanCheckUpdates() => !IsBusy;
|
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)
|
private async void PromptLauncherUpdate(LauncherInfo launcher)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -324,15 +368,15 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
if (!dialog.UpdateRequested) return;
|
if (!dialog.UpdateRequested) return;
|
||||||
|
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
StatusMessage = "Téléchargement du nouveau launcher…";
|
StatusMessage = Strings.StatusDownloadingLauncher;
|
||||||
var progress = new Progress<DownloadProgress>(p =>
|
var progress = new Progress<DownloadProgress>(p =>
|
||||||
{
|
{
|
||||||
if (p.TotalBytes > 0) ProgressPercent = (double)p.BytesDownloaded / p.TotalBytes * 100.0;
|
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);
|
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
|
await Task.Delay(800); // laisse le temps au toast / message de s'afficher
|
||||||
Application.Current.Shutdown();
|
Application.Current.Shutdown();
|
||||||
}
|
}
|
||||||
@@ -341,7 +385,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_logger.LogError(ex, "Self-update failed");
|
_logger.LogError(ex, "Self-update failed");
|
||||||
MessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message),
|
MessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message),
|
||||||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
StatusMessage = $"Erreur self-update : {ex.Message}";
|
StatusMessage = Strings.StatusSelfUpdateError(ex.Message);
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,7 +460,49 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[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 ------------
|
// ------------ Per-row actions ------------
|
||||||
|
|
||||||
@@ -488,14 +574,14 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
if (!isResume)
|
if (!isResume)
|
||||||
{
|
{
|
||||||
// 1) Récupère release notes (best effort)
|
// 1) Récupère release notes (best effort)
|
||||||
string notes = "_Aucune release note fournie._";
|
string notes = Strings.ReleaseNotesNone;
|
||||||
if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
|
if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
notes = await _manifestService.FetchReleaseNotesAsync(row.Remote.ReleaseNotesUrl, ct);
|
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
|
// 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 signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
||||||
var urlString = signed ?? row.Remote.Download.Url;
|
var urlString = signed ?? row.Remote.Download.Url;
|
||||||
var url = new Uri(urlString);
|
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));
|
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
|
||||||
StatusMessage = $"Téléchargement v{row.Version}…";
|
// Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
|
||||||
var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, ct);
|
// 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
|
// 4) Install
|
||||||
row.State = VersionRowState.Installing;
|
row.State = VersionRowState.Installing;
|
||||||
StatusMessage = $"Installation v{row.Version}…";
|
StatusMessage = Strings.StatusInstallingVersion(row.Version);
|
||||||
var folderName = row.Remote.GetInstallFolderName();
|
var folderName = row.Remote.GetInstallFolderName();
|
||||||
var target = Path.Combine(_config.InstallRoot, folderName);
|
var target = Path.Combine(_config.InstallRoot, folderName);
|
||||||
var installProgress = new Progress<InstallProgress>(ip =>
|
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;
|
var pct = (double)ip.BytesDone / ip.BytesTotal * 100.0;
|
||||||
ProgressPercent = pct;
|
ProgressPercent = pct;
|
||||||
row.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;
|
row.ProgressDetail = ProgressDetail;
|
||||||
});
|
});
|
||||||
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
|
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
|
||||||
|
|
||||||
try { File.Delete(zipPath); } catch { /* non critique */ }
|
try { File.Delete(zipPath); } catch { /* non critique */ }
|
||||||
|
|
||||||
StatusMessage = $"v{row.Version} installée avec succès";
|
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||||
ProgressDetail = null;
|
ProgressDetail = null;
|
||||||
ProgressPercent = 0;
|
ProgressPercent = 0;
|
||||||
RebuildList();
|
RebuildList();
|
||||||
_toastService.ShowSuccess(
|
_toastService.ShowSuccess(
|
||||||
"Installation terminée",
|
Strings.ToastInstallSuccessTitle,
|
||||||
$"PROSERVE v{row.Version} est prête à être lancée.");
|
Strings.ToastInstallSuccessBody(row.Version));
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
StatusMessage = "Téléchargement annulé";
|
StatusMessage = Strings.StatusDownloadCancelled;
|
||||||
row.State = VersionRowState.AvailableIdle;
|
row.State = VersionRowState.AvailableIdle;
|
||||||
row.ProgressDetail = null;
|
row.ProgressDetail = null;
|
||||||
row.ProgressPercent = 0;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -558,8 +683,11 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
Strings.MsgInstallFailed(ex.Message),
|
Strings.MsgInstallFailed(ex.Message),
|
||||||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
StatusMessage = $"Erreur : {ex.Message}";
|
StatusMessage = Strings.StatusError(ex.Message);
|
||||||
_toastService.ShowError($"Échec v{row.Version}", 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
|
finally
|
||||||
{
|
{
|
||||||
@@ -586,11 +714,11 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
row.State = VersionRowState.Uninstalling;
|
row.State = VersionRowState.Uninstalling;
|
||||||
StatusMessage = $"Suppression v{row.Version}…";
|
StatusMessage = Strings.StatusUninstallingVersion(row.Version);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _registry.DeleteAsync(row.Version, null, CancellationToken.None);
|
await _registry.DeleteAsync(row.Version, null, CancellationToken.None);
|
||||||
StatusMessage = $"v{row.Version} supprimée";
|
StatusMessage = Strings.StatusUninstalledVersion(row.Version);
|
||||||
RebuildList();
|
RebuildList();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -599,7 +727,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
row.State = VersionRowState.InstalledIdle;
|
row.State = VersionRowState.InstalledIdle;
|
||||||
MessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError,
|
MessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError,
|
||||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
StatusMessage = $"Erreur : {ex.Message}";
|
StatusMessage = Strings.StatusError(ex.Message);
|
||||||
}
|
}
|
||||||
finally
|
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)
|
private void UpdateDlProgress(VersionRowViewModel row, DownloadProgress p)
|
||||||
{
|
{
|
||||||
if (p.TotalBytes > 0)
|
if (p.TotalBytes > 0)
|
||||||
@@ -661,15 +808,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
row.ProgressDetail = s;
|
row.ProgressDetail = s;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatSize(long bytes)
|
private static string FormatSize(long bytes) => Strings.FormatSize(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 FormatEta(TimeSpan eta)
|
private static string FormatEta(TimeSpan eta)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.App.ViewModels;
|
namespace PSLauncher.App.ViewModels;
|
||||||
@@ -29,6 +30,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsInstalled))]
|
[NotifyPropertyChangedFor(nameof(IsInstalled))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
|
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
|
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
|
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
|
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
|
||||||
@@ -40,6 +42,8 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
|
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
|
||||||
[NotifyPropertyChangedFor(nameof(HasResumableDownload))]
|
[NotifyPropertyChangedFor(nameof(HasResumableDownload))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(RestartFromZeroCommand))]
|
||||||
private long _resumableBytes;
|
private long _resumableBytes;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -51,15 +55,23 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
public bool HasResumableDownload => ResumableBytes > 0;
|
public bool HasResumableDownload => ResumableBytes > 0;
|
||||||
public bool LicenseAllowsInstall => LicenseAllowsDownload;
|
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
|
public string InstallButtonLabel
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (!LicenseAllowsDownload) return "🔒 License insuffisante";
|
if (!LicenseAllowsDownload) return Strings.ActionLicenseInsufficient;
|
||||||
if (!HasResumableDownload) return "⬇ Installer";
|
if (!HasResumableDownload) return Strings.ActionInstall;
|
||||||
if (Remote is null || Remote.Download.SizeBytes <= 0) return "↻ Reprendre";
|
if (Remote is null || Remote.Download.SizeBytes <= 0) return Strings.ResumeButtonNoPercent;
|
||||||
var pct = (double)ResumableBytes / Remote.Download.SizeBytes * 100.0;
|
var pct = (int)Math.Round((double)ResumableBytes / Remote.Download.SizeBytes * 100.0);
|
||||||
return $"↻ Reprendre ({pct:0}%)";
|
return Strings.ResumeButton(pct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,16 +81,16 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
|
|
||||||
public string StateLabel => State switch
|
public string StateLabel => State switch
|
||||||
{
|
{
|
||||||
VersionRowState.InstalledIdle => "● Installée",
|
VersionRowState.InstalledIdle => Strings.StatusInstalled,
|
||||||
VersionRowState.AvailableIdle => "○ Disponible",
|
VersionRowState.AvailableIdle => Strings.StatusAvailable,
|
||||||
VersionRowState.Downloading => "⬇ Téléchargement…",
|
VersionRowState.Downloading => Strings.StatusDownloading,
|
||||||
VersionRowState.Installing => "📦 Installation…",
|
VersionRowState.Installing => Strings.StatusInstalling,
|
||||||
VersionRowState.Uninstalling => "🗑 Suppression…",
|
VersionRowState.Uninstalling => Strings.StatusUninstalling,
|
||||||
_ => string.Empty
|
_ => string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
public string PrimaryDate => ReleasedAt > DateTime.MinValue
|
public string PrimaryDate => ReleasedAt > DateTime.MinValue
|
||||||
? ReleasedAt.ToLocalTime().ToString("dd/MM/yyyy")
|
? Strings.FormatDate(ReleasedAt)
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
public string SizeDisplay => FormatSize(SizeBytes);
|
public string SizeDisplay => FormatSize(SizeBytes);
|
||||||
@@ -134,6 +146,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
public Action<VersionRowViewModel>? UninstallHandler { get; set; }
|
public Action<VersionRowViewModel>? UninstallHandler { get; set; }
|
||||||
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
|
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
|
||||||
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
|
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
|
||||||
|
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanLaunch))]
|
[RelayCommand(CanExecute = nameof(CanLaunch))]
|
||||||
private void Launch() => LaunchHandler?.Invoke(this);
|
private void Launch() => LaunchHandler?.Invoke(this);
|
||||||
@@ -153,13 +166,10 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenFolder() => OpenFolderHandler?.Invoke(this);
|
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)
|
private static string FormatSize(long bytes)
|
||||||
{
|
=> bytes <= 0 ? "—" : Strings.FormatSize(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]}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ using System.Windows;
|
|||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Controls.Primitives;
|
using System.Windows.Controls.Primitives;
|
||||||
using System.Windows.Interop;
|
using System.Windows.Interop;
|
||||||
|
using PSLauncher.App.ViewModels;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
@@ -27,6 +29,25 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
SourceInitialized += MainWindow_SourceInitialized;
|
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>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user