From 9de8e95910aa104aabd826b5a477b369eaeb0e15 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Mon, 4 May 2026 12:04:36 +0200 Subject: [PATCH] =?UTF-8?q?v0.24.5=20=E2=80=94=20Install=20Program=20Files?= =?UTF-8?q?,=20multi-PC=20DL=20reliability,=20polish=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- installer/PSLauncher.iss | 16 ++- installer/build-installer.ps1 | 55 +++++++- server/admin/licenses.php | 31 +++++ src/PSLauncher.App/PSLauncher.App.csproj | 9 +- .../ViewModels/MainViewModel.cs | 104 +++++++++++++-- .../ViewModels/SettingsViewModel.cs | 62 ++++++++- src/PSLauncher.App/Views/MainWindow.xaml | 124 +++++++++++------- src/PSLauncher.App/Views/MainWindow.xaml.cs | 66 ++++++++++ src/PSLauncher.App/Views/SettingsDialog.xaml | 20 ++- .../Downloads/DownloadManager.cs | 15 +++ src/PSLauncher.Models/LocalConfig.cs | 20 +-- 11 files changed, 439 insertions(+), 83 deletions(-) diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 6fb8789..9d6ae00 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -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 diff --git a/installer/build-installer.ps1 b/installer/build-installer.ps1 index 0c33989..cbfffd3 100644 --- a/installer/build-installer.ps1 +++ b/installer/build-installer.ps1 @@ -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 ." +$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 "" diff --git a/server/admin/licenses.php b/server/admin/licenses.php index c800d74..a03182a 100644 --- a/server/admin/licenses.php +++ b/server/admin/licenses.php @@ -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'); +
+ Slots +
+ + + + + +
+
0): ?>
diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index b50ce65..962aa88 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -9,15 +9,18 @@ enable latest enable + + true Resources\favicon.ico PS_Launcher ASTERION VR PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.23.6 - 0.23.6.0 - 0.23.6.0 + 0.24.5 + 0.24.5.0 + 0.24.5.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 04d1c8a..2d111f9 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -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; + /// + /// 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. + /// + public string AppVersion + { + get + { + var v = Assembly.GetExecutingAssembly().GetName().Version; + return v is null ? "v?" : $"v{v.Major}.{v.Minor}.{v.Build}"; + } + } + + /// + /// 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. + /// + 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 /// 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(); 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(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(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(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 diff --git a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs index c05c0f6..35fa987 100644 --- a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs +++ b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs @@ -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(); + } + + /// + /// Réordonnancement d'un check via les boutons ▲/▼ de la liste. delta = ±1. + /// La persistance vers _config.HealthChecks.Checks 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. + /// + 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; + } + + /// + /// 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. + /// + 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 _onDelete; + private readonly Action _onMove; + private readonly Func _canMove; - public HealthCheckRowViewModel(HealthCheckEntry entry, Action onDelete) + public HealthCheckRowViewModel( + HealthCheckEntry entry, + Action onDelete, + Action onMove, + Func 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); } /// Variante doc — même structure que . diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml index 8ed3582..49ec239 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -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. --> - - - - + + @@ -630,9 +619,11 @@ Command="{Binding CheckForUpdatesCommand}" /> + 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. --> @@ -677,33 +668,76 @@ Background="#0A0A0E" BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,1,0"> - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + +