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

@@ -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,33 +668,76 @@
Background="#0A0A0E"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,0,1,0">
<StackPanel Orientation="Vertical" Margin="0,16,0,0">
<!-- Style des boutons définis dans Theme.xaml (NavButton) -->
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateLibraryCommand}"
Tag="{Binding IsLibrary}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📚" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavLibrary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateReportCommand}"
Tag="{Binding IsReport}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📊" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavReport}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateDocumentationCommand}"
Tag="{Binding IsDocumentation}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📖" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavDocumentation}" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<DockPanel 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}"
Command="{Binding NavigateLibraryCommand}"
Tag="{Binding IsLibrary}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📚" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavLibrary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateReportCommand}"
Tag="{Binding IsReport}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📊" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavReport}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateDocumentationCommand}"
Tag="{Binding IsDocumentation}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📖" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavDocumentation}" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</DockPanel>
</Border>
<!-- 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}"