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:
@@ -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
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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 (1–100).');
|
||||||
|
}
|
||||||
|
// 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() ?>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,6 +668,48 @@
|
|||||||
Background="#0A0A0E"
|
Background="#0A0A0E"
|
||||||
BorderBrush="{StaticResource Brush.Border}"
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
BorderThickness="0,0,1,0">
|
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">
|
<StackPanel Orientation="Vertical" Margin="0,16,0,0">
|
||||||
<!-- Style des boutons définis dans Theme.xaml (NavButton) -->
|
<!-- Style des boutons définis dans Theme.xaml (NavButton) -->
|
||||||
<Button Style="{StaticResource NavButton}"
|
<Button Style="{StaticResource NavButton}"
|
||||||
@@ -704,6 +737,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
</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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user