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

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher" #define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher" #define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.23.6" #define MyAppVersion "0.24.5"
#define MyAppPublisher "ASTERION VR" #define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com" #define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe" #define MyAppExeName "PS_Launcher.exe"
@@ -28,9 +28,14 @@ AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL} AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL} AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL} AppUpdatesURL={#MyAppURL}
; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour ; Installation system-wide dans Program Files. {autopf} résout vers
; tout seul via PS_Launcher.Updater.exe sans déclencher de UAC. ; "C:\Program Files" sur cible 64-bit (cf. ArchitecturesInstallIn64BitMode=x64
DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName} ; ci-dessous). PrivilegesRequired=admin → UAC à l'install, et UAC à chaque
; self-update du launcher (LauncherSelfUpdater détecte automatiquement le
; dossier non-writable et lance PS_Launcher.Updater.exe avec Verb="runas").
; Le staging du nouveau .exe avant swap reste dans %LocalAppData%\PSLauncher\
; selfupdate\, user-writable et lisible depuis l'updater élevé.
DefaultDirName={autopf}\{#MyAppPublisher}\{#MyAppShortName}
DefaultGroupName={#MyAppName} DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
OutputDir=output OutputDir=output
@@ -39,8 +44,7 @@ SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
Compression=lzma2/ultra Compression=lzma2/ultra
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
PrivilegesRequired=lowest PrivilegesRequired=admin
PrivilegesRequiredOverridesAllowed=dialog
UsedUserAreasWarning=no UsedUserAreasWarning=no
ArchitecturesAllowed=x64 ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64 ArchitecturesInstallIn64BitMode=x64

View File

@@ -1,19 +1,48 @@
# Build complet : publish + Inno Setup # Build complet : publish + Inno Setup
# #
# Pré-requis : # Pre-requis :
# - .NET 8 SDK # - .NET 8 SDK
# - Inno Setup 6.x (https://jrsoftware.org/isdl.php) # - Inno Setup 6.x (https://jrsoftware.org/isdl.php)
# #
# Lancer depuis la racine du repo : # Lancer depuis la racine du repo :
# powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1 # powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1
#
# NB : ce fichier est volontairement sans accents pour rester compatible
# PowerShell 5.1 (qui lit les .ps1 sans BOM en Windows-1252, pas UTF-8).
param( param(
[string]$ISCC = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" [string]$ISCC = ""
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
# Resolution de ISCC.exe : argument explicite > emplacements connus > PATH.
function Find-Iscc {
param([string]$Hint)
if ($Hint -and (Test-Path $Hint)) { return $Hint }
# Couvre les installs system-wide (Program Files) et user-mode
# (%LocalAppData%\Programs, emplacement par defaut quand winget installe sans admin).
$candidates = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe",
"C:\Program Files (x86)\Inno Setup 5\ISCC.exe",
"C:\Program Files\Inno Setup 5\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 5\ISCC.exe"
)
foreach ($c in $candidates) {
if (Test-Path $c) { return $c }
}
$cmd = Get-Command iscc -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
Write-Host "==> dotnet publish PSLauncher.App (Release single-file)" -ForegroundColor Cyan Write-Host "==> dotnet publish PSLauncher.App (Release single-file)" -ForegroundColor Cyan
dotnet publish "$root\src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo dotnet publish "$root\src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.App failed" } if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.App failed" }
@@ -22,12 +51,26 @@ Write-Host "==> dotnet publish PSLauncher.Updater (Release single-file)" -Foregr
dotnet publish "$root\src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo dotnet publish "$root\src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.Updater failed" } if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.Updater failed" }
if (-not (Test-Path $ISCC)) { $resolved = Find-Iscc $ISCC
throw "ISCC.exe introuvable à $ISCC. Installe Inno Setup ou passe -ISCC <chemin>." if (-not $resolved) {
Write-Host ""
Write-Host "ERREUR : Inno Setup (ISCC.exe) introuvable." -ForegroundColor Red
Write-Host ""
Write-Host "Installe Inno Setup 6 depuis :" -ForegroundColor Yellow
Write-Host " https://jrsoftware.org/isdl.php" -ForegroundColor Yellow
Write-Host ""
Write-Host "Ou installation silencieuse via winget :" -ForegroundColor Yellow
Write-Host " winget install JRSoftware.InnoSetup" -ForegroundColor Yellow
Write-Host ""
Write-Host "Une fois installe, relance ce script. Si ISCC.exe est dans un emplacement" -ForegroundColor Yellow
Write-Host "non standard, passe le chemin via -ISCC :" -ForegroundColor Yellow
Write-Host " .\installer\build-installer.ps1 -ISCC 'D:\Tools\Inno\ISCC.exe'" -ForegroundColor Yellow
Write-Host ""
throw "ISCC.exe introuvable. Inno Setup n'est pas installe sur cette machine."
} }
Write-Host "==> Inno Setup compile" -ForegroundColor Cyan Write-Host "==> Inno Setup compile (ISCC: $resolved)" -ForegroundColor Cyan
& $ISCC "$PSScriptRoot\PSLauncher.iss" & $resolved "$PSScriptRoot\PSLauncher.iss"
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" } if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
Write-Host "" Write-Host ""

View File

@@ -75,6 +75,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
->execute([$newUntil . ' 23:59:59', $id]); ->execute([$newUntil . ' 23:59:59', $id]);
$message = "License #{$id} prolongée jusqu'au {$newUntil}."; $message = "License #{$id} prolongée jusqu'au {$newUntil}.";
} }
elseif ($action === 'set_max_machines') {
$id = (int)($_POST['id'] ?? 0);
$newMax = (int)($_POST['max_machines'] ?? 0);
if ($id <= 0 || $newMax < 1 || $newMax > 100) {
throw new Exception('Nombre de machines invalide (1100).');
}
// Refus de descendre sous le nombre de machines déjà activées —
// sinon état incohérent (machines_count > max_machines). L'admin doit
// d'abord libérer des slots via "Libérer" individuel ou "Libérer toutes".
$stmt = $db->prepare('SELECT COUNT(*) FROM license_machines WHERE license_id = ?');
$stmt->execute([$id]);
$current = (int)$stmt->fetchColumn();
if ($newMax < $current) {
throw new Exception("Impossible : {$current} machines sont déjà actives. Libère des slots d'abord ou choisis ≥ {$current}.");
}
$db->prepare('UPDATE licenses SET max_machines = ? WHERE id = ?')->execute([$newMax, $id]);
$message = "License #{$id} : limite passée à {$newMax} machine(s).";
}
elseif ($action === 'reset_machines') { elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]); $db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
@@ -214,6 +232,19 @@ Layout::header('Licenses', 'licenses');
<button class="btn btn-primary" type="submit">OK</button> <button class="btn btn-primary" type="submit">OK</button>
</form> </form>
</details> </details>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Slots</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_max_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="number" name="max_machines" min="1" max="100"
value="<?= (int)$l['max_machines'] ?>" required
style="width: 70px;"
title="Nombre max de machines (≥ <?= (int)$l['machines_count'] ?> actuellement actives)">
<button class="btn btn-primary" type="submit">OK</button>
</form>
</details>
<?php if ($l['machines_count'] > 0): ?> <?php if ($l['machines_count'] > 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')"> <form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>

View File

@@ -9,15 +9,18 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!-- Le générateur WinRT (CsWinRT v2.x, déclenché par WebView2/UWP refs sur SDK récent)
émet du code unsafe dans WinRTGenericInstantiation.g.cs. Sans ce flag, build = CS0227. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon> <ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
<AssemblyName>PS_Launcher</AssemblyName> <AssemblyName>PS_Launcher</AssemblyName>
<Company>ASTERION VR</Company> <Company>ASTERION VR</Company>
<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.23.6</Version> <Version>0.24.5</Version>
<AssemblyVersion>0.23.6.0</AssemblyVersion> <AssemblyVersion>0.24.5.0</AssemblyVersion>
<FileVersion>0.23.6.0</FileVersion> <FileVersion>0.24.5.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>

View File

@@ -1,6 +1,9 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Reflection;
using System.Windows; using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -248,6 +251,51 @@ public sealed partial class MainViewModel : ObservableObject
!string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail : !string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail :
StatusMessage ?? string.Empty; 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( public MainViewModel(
IInstallationRegistry registry, IInstallationRegistry registry,
IProcessLauncher processLauncher, IProcessLauncher processLauncher,
@@ -383,6 +431,15 @@ public sealed partial class MainViewModel : ObservableObject
/// </summary> /// </summary>
private void RebuildList() 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 installed = _registry.Scan().ToDictionary(v => v.Version);
var remote = _lastManifest?.Versions ?? new List<VersionManifest>(); var remote = _lastManifest?.Versions ?? new List<VersionManifest>();
var remoteByVer = remote.ToDictionary(v => v.Version); var remoteByVer = remote.ToDictionary(v => v.Version);
@@ -419,6 +476,26 @@ public sealed partial class MainViewModel : ObservableObject
&& _licenseService.CanDownloadVersion(_license, r.Remote); && _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 // Featured : plus haute installée, sinon plus haute distante
var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault(); var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault();
FeaturedVersion = featured; FeaturedVersion = featured;
@@ -809,20 +886,29 @@ public sealed partial class MainViewModel : ObservableObject
ProgressPercent = 0; ProgressPercent = 0;
row.ProgressDetail = null; row.ProgressDetail = null;
row.ProgressPercent = 0; 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). // 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 // On bascule la row active sur Verifying à la première remontée, ce qui change
// le badge de la row de "⬇ Téléchargement…" vers "🔍 Vérification…" — les // le badge de "⬇ Téléchargement…" vers "🔍 Vérification…" — les deux phases
// deux phases sont distinctes côté UX bien que `DownloadAsync` les enchaîne. // sont distinctes côté UX bien que `DownloadAsync` les enchaîne.
var hashProgress = new Progress<double>(pct => var hashProgress = new Progress<double>(pct =>
{ {
if (row.State == VersionRowState.Downloading) var target = _activeRow;
row.State = VersionRowState.Verifying; if (target is null) return;
if (target.State == VersionRowState.Downloading)
target.State = VersionRowState.Verifying;
var percent = (int)Math.Round(pct * 100.0); var percent = (int)Math.Round(pct * 100.0);
ProgressPercent = percent; ProgressPercent = percent;
row.ProgressPercent = percent; target.ProgressPercent = percent;
ProgressDetail = Strings.ProgressVerifying(row.Version, percent); ProgressDetail = Strings.ProgressVerifying(target.Version, percent);
row.ProgressDetail = ProgressDetail; target.ProgressDetail = ProgressDetail;
}); });
// NB: pas de StatusMessage = "Téléchargement…" ici — on garde « Préparation… » // NB: pas de StatusMessage = "Téléchargement…" ici — on garde « Préparation… »
// visible pendant la phase HEAD/SetLength, puis le footer passe directement // 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 // 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. // muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
foreach (var entry in config.HealthChecks.Checks) 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) // Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes)
_ = LoadReportBackupsAsync(); _ = LoadReportBackupsAsync();
@@ -673,8 +673,11 @@ public sealed partial class SettingsViewModel : ObservableObject
}; };
if (dlg.ShowDialog() == true && dlg.Saved) if (dlg.ShowDialog() == true && dlg.Saved)
{ {
HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck)); HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck, MoveHealthCheck, CanMoveHealthCheck));
OnPropertyChanged(nameof(HasHealthChecks)); 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; if (confirm != MessageBoxResult.Yes) return;
HealthChecks.Remove(row); HealthChecks.Remove(row);
OnPropertyChanged(nameof(HasHealthChecks)); 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() private static HealthCheckEntry CloneEntry(HealthCheckEntry src) => new()
@@ -788,11 +828,19 @@ public sealed partial class HealthCheckRowViewModel : ObservableObject
public string Kind => Entry.Kind; public string Kind => Entry.Kind;
private readonly Action<HealthCheckRowViewModel> _onDelete; 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; Entry = entry;
_onDelete = onDelete; _onDelete = onDelete;
_onMove = onMove;
_canMove = canMove;
_displayName = string.IsNullOrEmpty(entry.Name) ? "(sans nom)" : entry.Name; _displayName = string.IsNullOrEmpty(entry.Name) ? "(sans nom)" : entry.Name;
_displayIcon = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon; _displayIcon = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon;
_displayDetail = ComputeDetail(entry); _displayDetail = ComputeDetail(entry);
@@ -823,6 +871,14 @@ public sealed partial class HealthCheckRowViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private void Delete() => _onDelete(this); 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> /// <summary>Variante doc — même structure que <see cref="ReportBackupViewModel"/>.</summary>

View File

@@ -11,6 +11,7 @@
Width="1280" Height="720" Width="1280" Height="720"
MinWidth="980" MinHeight="600" MinWidth="980" MinHeight="600"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
WindowState="Maximized"
WindowStyle="None" WindowStyle="None"
ResizeMode="CanResize" ResizeMode="CanResize"
AllowsTransparency="False" AllowsTransparency="False"
@@ -193,22 +194,10 @@
de fenêtre. Le footer est confiné à la colonne content. de fenêtre. Le footer est confiné à la colonne content.
--> -->
<Grid Background="{StaticResource Brush.Bg.Window}"> <Grid Background="{StaticResource Brush.Bg.Window}">
<!-- Bug WPF connu avec WindowStyle=None + WindowChrome : en mode <!-- La fenêtre est correctement clampée à la work area du moniteur en mode
Maximized, la fenêtre déborde de ~7 px sur chaque bord (le Maximized via WM_GETMINMAXINFO (cf. MainWindow.xaml.cs). Plus besoin
ResizeBorderThickness physique reste). Sans compensation, le footer de hack via Margin sur trigger WindowState. -->
(Row 3) passe sous la barre des tâches et la barre de download
devient invisible. On applique un margin de 7 px en mode Maximized
via un trigger sur WindowState. -->
<Grid.Style>
<Style TargetType="Grid">
<Setter Property="Margin" Value="0" />
<Style.Triggers>
<DataTrigger Binding="{Binding WindowState, RelativeSource={RelativeSource AncestorType=Window}}" Value="Maximized">
<Setter Property="Margin" Value="7" />
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="200" /> <ColumnDefinition Width="200" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
@@ -630,9 +619,11 @@
Command="{Binding CheckForUpdatesCommand}" /> Command="{Binding CheckForUpdatesCommand}" />
<!-- Copyright flottant en bas centré, sur une pill sombre pour rester <!-- Copyright flottant en bas centré, sur une pill sombre pour rester
lisible quel que soit l'arrière-plan (image, voile noir, etc.). --> lisible quel que soit l'arrière-plan (image, voile noir, etc.).
Bottom margin volontairement faible pour rester collé au footer
(barre de DL) quand il s'affiche. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom" <Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
Margin="0,0,0,28" Margin="0,0,0,8"
Background="#A0000000" Background="#A0000000"
CornerRadius="10" CornerRadius="10"
Padding="10,4"> Padding="10,4">
@@ -677,33 +668,76 @@
Background="#0A0A0E" Background="#0A0A0E"
BorderBrush="{StaticResource Brush.Border}" BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,0,1,0"> BorderThickness="0,0,1,0">
<StackPanel Orientation="Vertical" Margin="0,16,0,0"> <DockPanel LastChildFill="True">
<!-- Style des boutons définis dans Theme.xaml (NavButton) --> <!-- Bloc d'info en bas de sidebar : version du launcher + IP locale.
<Button Style="{StaticResource NavButton}" Utilité diagnostic terrain : le support peut lire les deux infos
Command="{Binding NavigateLibraryCommand}" sans avoir à naviguer dans Windows ou About. DockPanel.Dock=Bottom
Tag="{Binding IsLibrary}"> pour rester collé au bas peu importe la hauteur de la fenêtre. -->
<StackPanel Orientation="Horizontal"> <Border DockPanel.Dock="Bottom"
<TextBlock Text="📚" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" /> BorderBrush="{StaticResource Brush.Border}"
<TextBlock Text="{x:Static loc:Strings.NavLibrary}" VerticalAlignment="Center" /> BorderThickness="0,1,0,0"
</StackPanel> Padding="16,10">
</Button> <!-- Grid 2 colonnes : la colonne label (Auto) prend la largeur du
<Button Style="{StaticResource NavButton}" plus long ("PS_Launcher:"), la colonne valeur démarre au même X
Command="{Binding NavigateReportCommand}" sur les deux lignes → version et IP visuellement alignées. -->
Tag="{Binding IsReport}"> <Grid>
<StackPanel Orientation="Horizontal"> <Grid.ColumnDefinitions>
<TextBlock Text="📊" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" /> <ColumnDefinition Width="Auto" />
<TextBlock Text="{x:Static loc:Strings.NavReport}" VerticalAlignment="Center" /> <ColumnDefinition Width="*" />
</StackPanel> </Grid.ColumnDefinitions>
</Button> <Grid.RowDefinitions>
<Button Style="{StaticResource NavButton}" <RowDefinition Height="Auto" />
Command="{Binding NavigateDocumentationCommand}" <RowDefinition Height="Auto" />
Tag="{Binding IsDocumentation}"> </Grid.RowDefinitions>
<StackPanel Orientation="Horizontal"> <TextBlock Grid.Row="0" Grid.Column="0"
<TextBlock Text="📖" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" /> Text="PS_Launcher"
<TextBlock Text="{x:Static loc:Strings.NavDocumentation}" VerticalAlignment="Center" /> Foreground="{StaticResource Brush.Text.Secondary}"
</StackPanel> FontSize="11" Margin="0,0,8,0"
</Button> ToolTip="Version du launcher" />
</StackPanel> <TextBlock Grid.Row="0" Grid.Column="1"
Text="{Binding AppVersion}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" />
<TextBlock Grid.Row="1" Grid.Column="0"
Text="IP"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="IPv4 locale du PC (interface avec gateway par défaut)" />
<TextBlock Grid.Row="1" Grid.Column="1"
Text="{Binding LocalIpAddress}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
</Grid>
</Border>
<StackPanel Orientation="Vertical" Margin="0,16,0,0">
<!-- Style des boutons définis dans Theme.xaml (NavButton) -->
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateLibraryCommand}"
Tag="{Binding IsLibrary}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📚" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavLibrary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateReportCommand}"
Tag="{Binding IsReport}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📊" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavReport}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateDocumentationCommand}"
Tag="{Binding IsDocumentation}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📖" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavDocumentation}" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</DockPanel>
</Border> </Border>
<!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone <!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone

View File

@@ -25,6 +25,66 @@ public partial class MainWindow : Window
private const int SW_RESTORE = 9; private const int SW_RESTORE = 9;
// ===== WM_GETMINMAXINFO : contraint la taille en mode Maximized à la work area =====
// Avec WindowStyle="None" + WindowChrome, Windows maximise la fenêtre à la taille
// PHYSIQUE du moniteur (incluant la zone de la taskbar), ce qui fait passer le footer
// sous la barre des tâches → barre de DL invisible. Le fix canonique est de hooker
// WM_GETMINMAXINFO et de remplacer ptMaxSize/ptMaxPosition par les coordonnées de
// la work area (rcWork) du moniteur sur lequel la fenêtre se trouve. Multi-écran-safe
// (gère même les taskbars sur écran secondaire) et insensible au DPI.
private const int WM_GETMINMAXINFO = 0x0024;
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr handle, uint flags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)]
private struct MINMAXINFO
{
public POINT ptReserved;
public POINT ptMaxSize;
public POINT ptMaxPosition;
public POINT ptMinTrackSize;
public POINT ptMaxTrackSize;
}
[StructLayout(LayoutKind.Sequential)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
private static void HandleGetMinMaxInfo(IntPtr hwnd, IntPtr lParam)
{
var monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (monitor == IntPtr.Zero) return;
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
if (!GetMonitorInfo(monitor, ref info)) return;
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
var work = info.rcWork;
var monitorRect = info.rcMonitor;
mmi.ptMaxPosition.X = Math.Abs(work.Left - monitorRect.Left);
mmi.ptMaxPosition.Y = Math.Abs(work.Top - monitorRect.Top);
mmi.ptMaxSize.X = Math.Abs(work.Right - work.Left);
mmi.ptMaxSize.Y = Math.Abs(work.Bottom - work.Top);
Marshal.StructureToPtr(mmi, lParam, true);
}
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
@@ -73,6 +133,12 @@ public partial class MainWindow : Window
SetForegroundWindow(hwnd); SetForegroundWindow(hwnd);
handled = true; handled = true;
} }
else if (msg == WM_GETMINMAXINFO)
{
HandleGetMinMaxInfo(hwnd, lParam);
// On laisse handled=false : Windows continue son traitement avec le MINMAXINFO
// qu'on vient de réécrire (clamp à la work area du moniteur).
}
return IntPtr.Zero; return IntPtr.Zero;
} }

View File

@@ -695,6 +695,8 @@
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" <TextBlock Grid.Column="0"
Text="{Binding DisplayIcon}" Text="{Binding DisplayIcon}"
@@ -710,13 +712,29 @@
FontSize="11" FontSize="11"
FontFamily="Consolas" /> FontFamily="Consolas" />
</StackPanel> </StackPanel>
<!-- Boutons ▲/▼ pour réordonner. CanExecute géré côté
ViewModel : grisés sur le premier/dernier item. -->
<Button Grid.Column="2" <Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="▲"
Command="{Binding MoveUpCommand}"
ToolTip="Monter dans la liste"
Margin="0,0,4,0"
Padding="10,6" />
<Button Grid.Column="3"
Style="{StaticResource SecondaryButton}"
Content="▼"
Command="{Binding MoveDownCommand}"
ToolTip="Descendre dans la liste"
Margin="0,0,12,0"
Padding="10,6" />
<Button Grid.Column="4"
Style="{StaticResource SecondaryButton}" Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthEdit}" Content="{x:Static loc:Strings.SettingsHealthEdit}"
Command="{Binding EditCommand}" Command="{Binding EditCommand}"
Margin="0,0,6,0" Margin="0,0,6,0"
Padding="12,6" /> Padding="12,6" />
<Button Grid.Column="3" <Button Grid.Column="5"
Style="{StaticResource SecondaryButton}" Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthDelete}" Content="{x:Static loc:Strings.SettingsHealthDelete}"
Command="{Binding DeleteCommand}" Command="{Binding DeleteCommand}"

View File

@@ -465,6 +465,21 @@ public sealed class DownloadManager : IDownloadManager
} }
await dst.FlushAsync(ct).ConfigureAwait(false); await dst.FlushAsync(ct).ConfigureAwait(false);
seg.Completed = (seg.DownloadedBytes >= seg.Length); seg.Completed = (seg.DownloadedBytes >= seg.Length);
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a
// fermé la connexion sans erreur HTTP (typique : PHP-FPM hit `request_terminate_timeout`
// sur un gros segment, OVH coupe la connexion silencieusement, proxy intermédiaire qui
// ferme). Sans cette détection, le segment retourne `Completed=false` mais sans exception,
// Polly ne retry pas, le fichier final a des trous (zones sparse jamais écrites = zéros
// NTFS) et le SHA-256 échoue à la fin. On lance une exception transitoire pour forcer
// un retry — celui-ci reprendra à l'offset où on s'est arrêté grâce à seg.DownloadedBytes.
if (!seg.Completed)
{
var missing = seg.Length - seg.DownloadedBytes;
throw new HttpResumableException(
$"Segment {seg.Index} : connexion fermée prématurément, {missing:N0} octets manquants (probablement timeout PHP-FPM serveur)",
isTransient: true);
}
} }
// =================================================================== // ===================================================================

View File

@@ -3,7 +3,7 @@ namespace PSLauncher.Models;
public sealed class LocalConfig public sealed class LocalConfig
{ {
public int SchemaVersion { get; set; } = 1; public int SchemaVersion { get; set; } = 1;
public string ServerBaseUrl { get; set; } = "https://example.com/PS_Launcher/api"; public string ServerBaseUrl { get; set; } = "https://asterionvr.com/PS_Launcher/api";
public string InstallRoot { get; set; } = string.Empty; public string InstallRoot { get; set; } = string.Empty;
public string? LastLaunchedVersion { get; set; } public string? LastLaunchedVersion { get; set; }
@@ -16,13 +16,13 @@ public sealed class LocalConfig
/// <summary> /// <summary>
/// Nombre de connexions HTTP parallèles utilisées pour télécharger un build. /// Nombre de connexions HTTP parallèles utilisées pour télécharger un build.
/// 1 = comportement legacy single-connection. 4-8 contourne le throttling /// 1 = comportement legacy single-connection. 4-8 contourne le throttling
/// per-connection d'Apache/OVH mutualisé. 16 = défaut depuis v0.19 pour /// per-connection d'Apache/OVH mutualisé. 6 = défaut conservateur pour
/// raccourcir la « queue » en fin de DL : avec 8 segments sur 14 Go, le /// permettre plusieurs PCs en simultané sans saturer le pool PHP-FPM
/// dernier segment doit finir seul ~1.75 Go (visible comme un effondrement /// (gate.php tient un worker par segment pendant tout le DL — 16 segments
/// du débit en fin de course) ; avec 16, c'est ~875 Mo. Push à 24/32 si /// × 2 PCs = 32 workers, ce qui dépasse la limite OVH mutualisé ~30).
/// ton serveur l'accepte (OVH mutualisé tient bien jusqu'à 16-24). /// Push à 12/16 sur un serveur dédié ou si un seul poste télécharge.
/// </summary> /// </summary>
public int ParallelDownloadSegments { get; set; } = 16; public int ParallelDownloadSegments { get; set; } = 6;
/// <summary> /// <summary>
/// Si false, ignore la vérification SHA-256 même si le manifest l'exige. /// Si false, ignore la vérification SHA-256 même si le manifest l'exige.
@@ -176,7 +176,7 @@ public sealed class HealthChecksConfig
Name = "Vive Business Streaming", Name = "Vive Business Streaming",
Icon = "📡", Icon = "📡",
Kind = "Process", Kind = "Process",
Target = "HtcConnectionUtility", Target = "rrserver",
}, },
// PAS de check « casque connecté » par défaut. // PAS de check « casque connecté » par défaut.
// //
@@ -216,9 +216,9 @@ public sealed class HealthCheckEntry
/// Période entre 2 vérifications de CE check, en millisecondes. Indépendant /// Période entre 2 vérifications de CE check, en millisecondes. Indépendant
/// pour chaque entry car un Process check est rapide (~1ms) et peut tourner /// pour chaque entry car un Process check est rapide (~1ms) et peut tourner
/// toutes les secondes, alors qu'un Ping coûte une requête réseau et peut /// toutes les secondes, alors qu'un Ping coûte une requête réseau et peut
/// tourner toutes les 5-10 s. Default 5000 ms. /// tourner toutes les 5-10 s. Default 2000 ms.
/// </summary> /// </summary>
public int RefreshIntervalMs { get; set; } = 5000; public int RefreshIntervalMs { get; set; } = 2000;
/// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.</summary> /// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.</summary>
public int? PingWarnMs { get; set; } = 50; public int? PingWarnMs { get; set; } = 50;