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 MyAppShortName "PS_Launcher"
#define MyAppVersion "0.23.6"
#define MyAppVersion "0.24.5"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"
@@ -28,9 +28,14 @@ AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour
; tout seul via PS_Launcher.Updater.exe sans déclencher de UAC.
DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName}
; Installation system-wide dans Program Files. {autopf} résout vers
; "C:\Program Files" sur cible 64-bit (cf. ArchitecturesInstallIn64BitMode=x64
; 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}
DisableProgramGroupPage=yes
OutputDir=output
@@ -39,8 +44,7 @@ SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
Compression=lzma2/ultra
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
PrivilegesRequired=admin
UsedUserAreasWarning=no
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64

View File

@@ -1,19 +1,48 @@
# Build complet : publish + Inno Setup
#
# Pré-requis :
# Pre-requis :
# - .NET 8 SDK
# - Inno Setup 6.x (https://jrsoftware.org/isdl.php)
#
# Lancer depuis la racine du repo :
# 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(
[string]$ISCC = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
[string]$ISCC = ""
)
$ErrorActionPreference = 'Stop'
$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
dotnet publish "$root\src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
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
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.Updater failed" }
if (-not (Test-Path $ISCC)) {
throw "ISCC.exe introuvable à $ISCC. Installe Inno Setup ou passe -ISCC <chemin>."
$resolved = Find-Iscc $ISCC
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
& $ISCC "$PSScriptRoot\PSLauncher.iss"
Write-Host "==> Inno Setup compile (ISCC: $resolved)" -ForegroundColor Cyan
& $resolved "$PSScriptRoot\PSLauncher.iss"
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
Write-Host ""

View File

@@ -75,6 +75,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
->execute([$newUntil . ' 23:59:59', $id]);
$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') {
$id = (int)($_POST['id'] ?? 0);
$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>
</form>
</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): ?>
<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() ?>

View File

@@ -9,15 +9,18 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<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>
<AssemblyName>PS_Launcher</AssemblyName>
<Company>ASTERION VR</Company>
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.23.6</Version>
<AssemblyVersion>0.23.6.0</AssemblyVersion>
<FileVersion>0.23.6.0</FileVersion>
<Version>0.24.5</Version>
<AssemblyVersion>0.24.5.0</AssemblyVersion>
<FileVersion>0.24.5.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

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>

View File

@@ -11,6 +11,7 @@
Width="1280" Height="720"
MinWidth="980" MinHeight="600"
WindowStartupLocation="CenterScreen"
WindowState="Maximized"
WindowStyle="None"
ResizeMode="CanResize"
AllowsTransparency="False"
@@ -193,22 +194,10 @@
de fenêtre. Le footer est confiné à la colonne content.
-->
<Grid Background="{StaticResource Brush.Bg.Window}">
<!-- Bug WPF connu avec WindowStyle=None + WindowChrome : en mode
Maximized, la fenêtre déborde de ~7 px sur chaque bord (le
ResizeBorderThickness physique reste). Sans compensation, le footer
(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>
<!-- La fenêtre est correctement clampée à la work area du moniteur en mode
Maximized via WM_GETMINMAXINFO (cf. MainWindow.xaml.cs). Plus besoin
de hack via Margin sur trigger WindowState. -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
@@ -630,9 +619,11 @@
Command="{Binding CheckForUpdatesCommand}" />
<!-- 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"
Margin="0,0,0,28"
Margin="0,0,0,8"
Background="#A0000000"
CornerRadius="10"
Padding="10,4">
@@ -677,6 +668,48 @@
Background="#0A0A0E"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,0,1,0">
<DockPanel LastChildFill="True">
<!-- Bloc d'info en bas de sidebar : version du launcher + IP locale.
Utilité diagnostic terrain : le support peut lire les deux infos
sans avoir à naviguer dans Windows ou About. DockPanel.Dock=Bottom
pour rester collé au bas peu importe la hauteur de la fenêtre. -->
<Border DockPanel.Dock="Bottom"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,1,0,0"
Padding="16,10">
<!-- Grid 2 colonnes : la colonne label (Auto) prend la largeur du
plus long ("PS_Launcher:"), la colonne valeur démarre au même X
sur les deux lignes → version et IP visuellement alignées. -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0"
Text="PS_Launcher"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,0,8,0"
ToolTip="Version du launcher" />
<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}"
@@ -704,6 +737,7 @@
</StackPanel>
</Button>
</StackPanel>
</DockPanel>
</Border>
<!-- 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;
// ===== 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()
{
InitializeComponent();
@@ -73,6 +133,12 @@ public partial class MainWindow : Window
SetForegroundWindow(hwnd);
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;
}

View File

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

View File

@@ -465,6 +465,21 @@ public sealed class DownloadManager : IDownloadManager
}
await dst.FlushAsync(ct).ConfigureAwait(false);
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 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? LastLaunchedVersion { get; set; }
@@ -16,13 +16,13 @@ public sealed class LocalConfig
/// <summary>
/// Nombre de connexions HTTP parallèles utilisées pour télécharger un build.
/// 1 = comportement legacy single-connection. 4-8 contourne le throttling
/// per-connection d'Apache/OVH mutualisé. 16 = défaut depuis v0.19 pour
/// raccourcir la « queue » en fin de DL : avec 8 segments sur 14 Go, le
/// dernier segment doit finir seul ~1.75 Go (visible comme un effondrement
/// du débit en fin de course) ; avec 16, c'est ~875 Mo. Push à 24/32 si
/// ton serveur l'accepte (OVH mutualisé tient bien jusqu'à 16-24).
/// per-connection d'Apache/OVH mutualisé. 6 = défaut conservateur pour
/// permettre plusieurs PCs en simultané sans saturer le pool PHP-FPM
/// (gate.php tient un worker par segment pendant tout le DL — 16 segments
/// × 2 PCs = 32 workers, ce qui dépasse la limite OVH mutualisé ~30).
/// Push à 12/16 sur un serveur dédié ou si un seul poste télécharge.
/// </summary>
public int ParallelDownloadSegments { get; set; } = 16;
public int ParallelDownloadSegments { get; set; } = 6;
/// <summary>
/// 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",
Icon = "📡",
Kind = "Process",
Target = "HtcConnectionUtility",
Target = "rrserver",
},
// 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
/// 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
/// tourner toutes les 5-10 s. Default 5000 ms.
/// tourner toutes les 5-10 s. Default 2000 ms.
/// </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>
public int? PingWarnMs { get; set; } = 50;