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:
@@ -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,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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user