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 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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 (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') {
|
||||
$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() ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user