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

View File

@@ -207,7 +207,7 @@ public sealed partial class SettingsViewModel : ObservableObject
// Snapshot éditable des health checks. On clone chaque entry pour ne pas
// muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
foreach (var entry in config.HealthChecks.Checks)
HealthChecks.Add(new HealthCheckRowViewModel(CloneEntry(entry), DeleteHealthCheck));
HealthChecks.Add(new HealthCheckRowViewModel(CloneEntry(entry), DeleteHealthCheck, MoveHealthCheck, CanMoveHealthCheck));
// Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes)
_ = LoadReportBackupsAsync();
@@ -673,8 +673,11 @@ public sealed partial class SettingsViewModel : ObservableObject
};
if (dlg.ShowDialog() == true && dlg.Saved)
{
HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck));
HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck, MoveHealthCheck, CanMoveHealthCheck));
OnPropertyChanged(nameof(HasHealthChecks));
// Le nouveau dernier item peut maintenant monter ; l'avant-dernier
// peut maintenant descendre. Refresh global.
RefreshHealthMoveCanExecute();
}
}
@@ -687,6 +690,43 @@ public sealed partial class SettingsViewModel : ObservableObject
if (confirm != MessageBoxResult.Yes) return;
HealthChecks.Remove(row);
OnPropertyChanged(nameof(HasHealthChecks));
RefreshHealthMoveCanExecute();
}
/// <summary>
/// Réordonnancement d'un check via les boutons ▲/▼ de la liste. delta = ±1.
/// La persistance vers <c>_config.HealthChecks.Checks</c> se fait au Save() —
/// d'ici là, on ne mute que la collection observable. ObservableCollection.Move
/// préserve les sélections WPF et déplace l'item sans le re-créer.
/// </summary>
private void MoveHealthCheck(HealthCheckRowViewModel row, int delta)
{
var idx = HealthChecks.IndexOf(row);
var newIdx = idx + delta;
if (idx < 0 || newIdx < 0 || newIdx >= HealthChecks.Count) return;
HealthChecks.Move(idx, newIdx);
RefreshHealthMoveCanExecute();
}
private bool CanMoveHealthCheck(HealthCheckRowViewModel row, int delta)
{
var idx = HealthChecks.IndexOf(row);
var newIdx = idx + delta;
return idx >= 0 && newIdx >= 0 && newIdx < HealthChecks.Count;
}
/// <summary>
/// Re-publie CanExecute sur les commandes ▲/▼ de toutes les lignes : après
/// un déplacement (ou un add/delete), les boundaries changent et les boutons
/// doivent griser/ungrise sur les premiers/derniers items.
/// </summary>
private void RefreshHealthMoveCanExecute()
{
foreach (var r in HealthChecks)
{
r.MoveUpCommand.NotifyCanExecuteChanged();
r.MoveDownCommand.NotifyCanExecuteChanged();
}
}
private static HealthCheckEntry CloneEntry(HealthCheckEntry src) => new()
@@ -788,11 +828,19 @@ public sealed partial class HealthCheckRowViewModel : ObservableObject
public string Kind => Entry.Kind;
private readonly Action<HealthCheckRowViewModel> _onDelete;
private readonly Action<HealthCheckRowViewModel, int> _onMove;
private readonly Func<HealthCheckRowViewModel, int, bool> _canMove;
public HealthCheckRowViewModel(HealthCheckEntry entry, Action<HealthCheckRowViewModel> onDelete)
public HealthCheckRowViewModel(
HealthCheckEntry entry,
Action<HealthCheckRowViewModel> onDelete,
Action<HealthCheckRowViewModel, int> onMove,
Func<HealthCheckRowViewModel, int, bool> canMove)
{
Entry = entry;
_onDelete = onDelete;
_onMove = onMove;
_canMove = canMove;
_displayName = string.IsNullOrEmpty(entry.Name) ? "(sans nom)" : entry.Name;
_displayIcon = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon;
_displayDetail = ComputeDetail(entry);
@@ -823,6 +871,14 @@ public sealed partial class HealthCheckRowViewModel : ObservableObject
[RelayCommand]
private void Delete() => _onDelete(this);
[RelayCommand(CanExecute = nameof(CanMoveUp))]
private void MoveUp() => _onMove(this, -1);
private bool CanMoveUp() => _canMove(this, -1);
[RelayCommand(CanExecute = nameof(CanMoveDown))]
private void MoveDown() => _onMove(this, +1);
private bool CanMoveDown() => _canMove(this, +1);
}
/// <summary>Variante doc — même structure que <see cref="ReportBackupViewModel"/>.</summary>