v0.24.5 — Install Program Files, multi-PC DL reliability, polish UI

Installeur :
- Bascule en Program Files (autopf) avec PrivilegesRequired=admin.
  L'auto-update reste fonctionnel : LauncherSelfUpdater détecte le dossier
  protégé et lance PS_Launcher.Updater.exe via Verb=runas (UAC à chaque MAJ).
- build-installer.ps1 : détection ISCC.exe robuste (Program Files + LocalAppData
  pour install winget user-mode + PATH), messages d'erreur explicites avec URL
  et commande winget, suppression accents (compat PS 5.1 sans BOM).

Téléchargements multi-PC :
- DownloadManager : détection des connexions fermées prématurément (PHP-FPM
  request_terminate_timeout). Sans ça, segment marqué Completed=false sans
  exception → fichier sparse final avec trous → SHA-256 KO. Lance maintenant
  HttpResumableException(transient) pour que Polly retry au bon offset.
- ParallelDownloadSegments : 16 → 6 par défaut pour permettre plusieurs PCs
  simultanés sans saturer le pool PHP-FPM OVH (~30 workers).

UI :
- Fenêtre maximisée par défaut au démarrage (WindowState=Maximized).
- Fix maximize qui cachait le footer / barre de DL (WM_GETMINMAXINFO clamp
  sur work area, remplace le margin hack 7px imprécis).
- Sidebar : bloc info en bas avec PS_Launcher vX.Y.Z + IPv4 locale alignés.
- Copyright remonté plus près du footer (margin 28 → 8).
- Settings → Health checks : boutons ▲/▼ pour réordonner, CanExecute auto.
- HealthCheck refresh défaut : 5000 → 2000 ms.

Bug fix UI :
- MainViewModel.RebuildList preserve l'état du row actif pendant un DL.
  Sinon ouvrir Settings/License pendant un DL recréait les rows from scratch
  → UI affichait "Reprendre" + "Annuler" alors que le DL tournait. Les
  progress callbacks pointent maintenant sur _activeRow (résolution dynamique)
  au lieu de capturer le row local.

Defaults config :
- ServerBaseUrl : example.com → asterionvr.com (out-of-the-box).
- Vive Business Streaming check : HtcConnectionUtility → rrserver.

Backoffice (server/admin/licenses.php) :
- Action set_max_machines : bouton "Slots" pour ajuster max_machines à chaud
  sur une licence existante. Refus de descendre sous le nombre de machines
  déjà actives.

Build :
- AllowUnsafeBlocks=true sur PSLauncher.App.csproj (compat WinRT generator
  récent qui émet du code unsafe dans WinRTGenericInstantiation.g.cs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 12:04:36 +02:00
parent be7ccfced5
commit 9de8e95910
11 changed files with 439 additions and 83 deletions

View File

@@ -1,6 +1,9 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Reflection;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -248,6 +251,51 @@ public sealed partial class MainViewModel : ObservableObject
!string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail :
StatusMessage ?? string.Empty;
/// <summary>
/// Version courte du launcher au format "v0.24.0", affichée dans la sidebar.
/// Lit l'AssemblyVersion (Major.Minor.Build) — le 4e champ (Revision) toujours .0
/// est tronqué pour rester lisible.
/// </summary>
public string AppVersion
{
get
{
var v = Assembly.GetExecutingAssembly().GetName().Version;
return v is null ? "v?" : $"v{v.Major}.{v.Minor}.{v.Build}";
}
}
/// <summary>
/// IPv4 locale du PC affichée dans la sidebar pour le diagnostic terrain
/// (le support peut demander "quelle IP a ce poste ?" sans naviguer dans
/// les paramètres Windows). On filtre sur l'interface qui a une default
/// gateway IPv4 — élimine loopback, Hyper-V virtual switch, VPN inactifs,
/// et autres interfaces sans route. "—" si aucune connectivité réseau.
/// </summary>
public string LocalIpAddress
{
get
{
try
{
var ip = NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up
&& n.NetworkInterfaceType != NetworkInterfaceType.Loopback
&& n.GetIPProperties().GatewayAddresses
.Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork))
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(a => a.Address.ToString())
.FirstOrDefault();
return ip ?? "—";
}
catch
{
return "—";
}
}
}
public MainViewModel(
IInstallationRegistry registry,
IProcessLauncher processLauncher,
@@ -383,6 +431,15 @@ public sealed partial class MainViewModel : ObservableObject
/// </summary>
private void RebuildList()
{
// Si un DL/verify est en cours, on capture la row active pour transférer
// son état (Downloading/Verifying + progression) sur la nouvelle instance.
// Sans ça, l'UI repasse sur le nouveau row qui est en AvailableIdle avec
// ResumableBytes lu depuis state.json → boutons "Reprendre" + "Annuler"
// affichés alors que le DL continue derrière (footer qui avance).
var oldActive = _activeRow;
var preserveActive = oldActive is not null
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
var installed = _registry.Scan().ToDictionary(v => v.Version);
var remote = _lastManifest?.Versions ?? new List<VersionManifest>();
var remoteByVer = remote.ToDictionary(v => v.Version);
@@ -419,6 +476,26 @@ public sealed partial class MainViewModel : ObservableObject
&& _licenseService.CanDownloadVersion(_license, r.Remote);
}
// Si un DL était en vol, propage son état sur la nouvelle instance avant
// que les bindings UI ne s'attachent : State Downloading/Verifying +
// progression copiés, ResumableBytes forcé à 0 pour cacher les boutons
// "Reprendre" / "Annuler" tant que le DL tourne. _activeRow est repointé
// sur la nouvelle instance pour que les progress callbacks (résolus à
// l'exécution via _activeRow, pas via une capture closure) ciblent le
// bon objet et que la barre de progress de la row avance live.
if (preserveActive && oldActive is not null)
{
var matching = rows.FirstOrDefault(r => r.Version == oldActive.Version);
if (matching is not null)
{
matching.State = oldActive.State;
matching.ProgressPercent = oldActive.ProgressPercent;
matching.ProgressDetail = oldActive.ProgressDetail;
matching.ResumableBytes = 0;
_activeRow = matching;
}
}
// Featured : plus haute installée, sinon plus haute distante
var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault();
FeaturedVersion = featured;
@@ -809,20 +886,29 @@ public sealed partial class MainViewModel : ObservableObject
ProgressPercent = 0;
row.ProgressDetail = null;
row.ProgressPercent = 0;
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
// Les progress callbacks ciblent _activeRow (résolution dynamique) plutôt que
// de capturer le local `row` par closure — sinon, si un RebuildList intervient
// en cours de DL (cf. OpenSettings/OpenLicense), l'instance row capturée serait
// orpheline et la barre per-row n'avancerait plus côté UI.
var dlProgress = new Progress<DownloadProgress>(p =>
{
if (_activeRow is { } target) UpdateDlProgress(target, p);
});
// Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
// On bascule row.State sur Verifying à la première remontée, ce qui change
// le badge de la row de "⬇ Téléchargement…" vers "🔍 Vérification…" — les
// deux phases sont distinctes côté UX bien que `DownloadAsync` les enchaîne.
// On bascule la row active sur Verifying à la première remontée, ce qui change
// le badge de "⬇ Téléchargement…" vers "🔍 Vérification…" — les deux phases
// sont distinctes côté UX bien que `DownloadAsync` les enchaîne.
var hashProgress = new Progress<double>(pct =>
{
if (row.State == VersionRowState.Downloading)
row.State = VersionRowState.Verifying;
var target = _activeRow;
if (target is null) return;
if (target.State == VersionRowState.Downloading)
target.State = VersionRowState.Verifying;
var percent = (int)Math.Round(pct * 100.0);
ProgressPercent = percent;
row.ProgressPercent = percent;
ProgressDetail = Strings.ProgressVerifying(row.Version, percent);
row.ProgressDetail = ProgressDetail;
target.ProgressPercent = percent;
ProgressDetail = Strings.ProgressVerifying(target.Version, percent);
target.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