From eeff3c007b955a75c6db116a5674d6a885ccfcd6 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Fri, 1 May 2026 11:35:22 +0200 Subject: [PATCH] v0.5: settings dialog, persistent Serilog logs, Windows toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings (⚙ button in top bar) ------------------------------ SettingsDialog opens via OpenSettings command in MainViewModel. Sections: - Serveur: server URL field + "Tester" button that GETs /api/health and reports status inline. - Installation: installRoot path with Browse button (Microsoft.Win32.OpenFolderDialog, .NET 8 native). - License: shows status / owner / exp / machine ID (read-only, copy-to-clipboard button), "Désactiver" wipes the cached license. - Cache: shows download cache path + current size, opens or empties it. - Logs & Application: launcher version + logs path with "Open" button. Apply persists to LocalConfig via IConfigStore. After save, MainViewModel RebuildList()s so changes to ServerBaseUrl or InstallRoot take effect without restart. Persistent Serilog logs ----------------------- Logs now live in %LocalAppData%/PSLauncher/logs/app-YYYYMMDD.log, rolling daily, 10 days kept. Output template includes source context and stack traces. Generic Host wired up via .UseSerilog() so all ILogger-injected types share the sink. Unhandled AppDomain and Dispatcher exceptions are routed to Serilog before propagation. Windows toasts -------------- IToastService + ToastService backed by Microsoft.Toolkit.Uwp.Notifications (ToastContentBuilder.Show()). Required bumping the App TFM from net8.0-windows to net8.0-windows10.0.17763.0 (with explicit WindowsSdkPackageVersion=10.0.17763.41 to satisfy CommunityToolkit.Mvvm 8.3.2's MVVMTKCFG0003 check). Triggered on: - successful install completion: "Proserve v{X} est prête à être lancée" - install/download error: short error excerpt Misc ---- - InverseBoolConverter for "disable button while busy" patterns. - Added Markdig.Wpf import to ReleaseNotesViewerDialog (was implicit before, now required explicitly). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/PSLauncher.App/App.xaml.cs | 42 +++- src/PSLauncher.App/PSLauncher.App.csproj | 9 +- .../Resources/InverseBoolConverter.cs | 13 + src/PSLauncher.App/Resources/Theme.xaml | 3 + src/PSLauncher.App/Services/IToastService.cs | 8 + src/PSLauncher.App/Services/ToastService.cs | 32 +++ .../ViewModels/MainViewModel.cs | 26 ++ .../ViewModels/SettingsViewModel.cs | 203 +++++++++++++++ src/PSLauncher.App/Views/MainWindow.xaml | 6 +- src/PSLauncher.App/Views/SettingsDialog.xaml | 232 ++++++++++++++++++ .../Views/SettingsDialog.xaml.cs | 28 +++ 11 files changed, 597 insertions(+), 5 deletions(-) create mode 100644 src/PSLauncher.App/Resources/InverseBoolConverter.cs create mode 100644 src/PSLauncher.App/Services/IToastService.cs create mode 100644 src/PSLauncher.App/Services/ToastService.cs create mode 100644 src/PSLauncher.App/ViewModels/SettingsViewModel.cs create mode 100644 src/PSLauncher.App/Views/SettingsDialog.xaml create mode 100644 src/PSLauncher.App/Views/SettingsDialog.xaml.cs diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 13a48d6..6dda947 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -1,9 +1,11 @@ +using System.IO; using System.Net.Http; using System.Net.Http.Headers; using System.Windows; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using PSLauncher.App.Services; using PSLauncher.App.ViewModels; using PSLauncher.App.Views; using PSLauncher.Core.Configuration; @@ -15,6 +17,7 @@ using PSLauncher.Core.Manifests; using PSLauncher.Core.Process; using PSLauncher.Core.Updates; using PSLauncher.Models; +using Serilog; namespace PSLauncher.App; @@ -22,18 +25,46 @@ public partial class App : Application { private IHost? _host; + public static string LogsDirectory { get; private set; } = string.Empty; + protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + // Logs avant tout — pour ne rien perdre des erreurs au boot + LogsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "PSLauncher", "logs"); + Directory.CreateDirectory(LogsDirectory); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .Enrich.FromLogContext() + .WriteTo.Debug() + .WriteTo.File( + path: Path.Combine(LogsDirectory, "app-.log"), + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 10, + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}") + .CreateLogger(); + + Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory); + + AppDomain.CurrentDomain.UnhandledException += (_, args) => + Log.Fatal((Exception)args.ExceptionObject, "Unhandled exception (AppDomain)"); + DispatcherUnhandledException += (_, args) => + { + Log.Error(args.Exception, "Unhandled UI exception"); + args.Handled = false; + }; + _host = Host.CreateDefaultBuilder() - .ConfigureLogging(b => { b.ClearProviders(); b.AddDebug(); }) + .UseSerilog() .ConfigureServices((_, services) => { services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService().Load()); - // HttpClient partagé (timeout long pour les gros téléchargements) services.AddSingleton(sp => { var http = new HttpClient(new SocketsHttpHandler @@ -44,7 +75,7 @@ public partial class App : Application { Timeout = Timeout.InfiniteTimeSpan }; - http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PSLauncher", "0.2")); + http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PSLauncher", "0.5")); return http; }); @@ -75,6 +106,9 @@ public partial class App : Application sp.GetRequiredService(), sp.GetRequiredService>())); + services.AddSingleton(); + + services.AddTransient(); services.AddSingleton(); services.AddSingleton(); }) @@ -88,6 +122,8 @@ public partial class App : Application protected override void OnExit(ExitEventArgs e) { + Log.Information("PSLauncher shutting down"); + Log.CloseAndFlush(); _host?.Dispose(); base.OnExit(e); } diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 340e77b..96957df 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -2,7 +2,9 @@ WinExe - net8.0-windows + net8.0-windows10.0.17763.0 + 10.0.17763.0 + 10.0.17763.41 true enable latest @@ -27,6 +29,11 @@ + + + + + diff --git a/src/PSLauncher.App/Resources/InverseBoolConverter.cs b/src/PSLauncher.App/Resources/InverseBoolConverter.cs new file mode 100644 index 0000000..d3cbce6 --- /dev/null +++ b/src/PSLauncher.App/Resources/InverseBoolConverter.cs @@ -0,0 +1,13 @@ +using System.Globalization; +using System.Windows.Data; + +namespace PSLauncher.App.Resources; + +public sealed class InverseBoolConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool b ? !b : true; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is bool b ? !b : false; +} diff --git a/src/PSLauncher.App/Resources/Theme.xaml b/src/PSLauncher.App/Resources/Theme.xaml index d6e0ce3..847e739 100644 --- a/src/PSLauncher.App/Resources/Theme.xaml +++ b/src/PSLauncher.App/Resources/Theme.xaml @@ -3,6 +3,9 @@ + + + diff --git a/src/PSLauncher.App/Services/IToastService.cs b/src/PSLauncher.App/Services/IToastService.cs new file mode 100644 index 0000000..c82d144 --- /dev/null +++ b/src/PSLauncher.App/Services/IToastService.cs @@ -0,0 +1,8 @@ +namespace PSLauncher.App.Services; + +public interface IToastService +{ + void ShowSuccess(string title, string message); + void ShowError(string title, string message); + void ShowInfo(string title, string message); +} diff --git a/src/PSLauncher.App/Services/ToastService.cs b/src/PSLauncher.App/Services/ToastService.cs new file mode 100644 index 0000000..3e77cc4 --- /dev/null +++ b/src/PSLauncher.App/Services/ToastService.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Toolkit.Uwp.Notifications; + +namespace PSLauncher.App.Services; + +public sealed class ToastService : IToastService +{ + private readonly ILogger _logger; + + public ToastService(ILogger logger) => _logger = logger; + + public void ShowSuccess(string title, string message) => Show(title, message, "PROSERVE"); + public void ShowError(string title, string message) => Show(title, message, "PROSERVE"); + public void ShowInfo(string title, string message) => Show(title, message, "PROSERVE"); + + private void Show(string title, string message, string attribution) + { + try + { + new ToastContentBuilder() + .AddText(title) + .AddText(message) + .AddAttributionText(attribution) + .Show(); + } + catch (Exception ex) + { + // Si Action Center n'est pas dispo (ancien Windows, COM bloqué…), on log mais on ne plante pas + _logger.LogWarning(ex, "Failed to show Windows toast"); + } + } +} diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 0b36e9c..d58192b 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -6,6 +6,8 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using PSLauncher.App.Views; +using Microsoft.Extensions.DependencyInjection; +using PSLauncher.App.Services; using PSLauncher.Core.Configuration; using PSLauncher.Core.Downloads; using PSLauncher.Core.Installations; @@ -28,6 +30,8 @@ public sealed partial class MainViewModel : ObservableObject private readonly IDownloadManager _downloadManager; private readonly IZipInstaller _zipInstaller; private readonly ILicenseService _licenseService; + private readonly IToastService _toastService; + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private LicenseValidationResponse? _license; @@ -101,6 +105,8 @@ public sealed partial class MainViewModel : ObservableObject IDownloadManager downloadManager, IZipInstaller zipInstaller, ILicenseService licenseService, + IToastService toastService, + IServiceProvider serviceProvider, ILogger logger) { _registry = registry; @@ -112,6 +118,8 @@ public sealed partial class MainViewModel : ObservableObject _downloadManager = downloadManager; _zipInstaller = zipInstaller; _licenseService = licenseService; + _toastService = toastService; + _serviceProvider = serviceProvider; _logger = logger; // Charge la license depuis le cache (pas d'appel réseau au démarrage, @@ -226,6 +234,20 @@ public sealed partial class MainViewModel : ObservableObject } private bool CanCheckUpdates() => !IsBusy; + [RelayCommand] + private void OpenSettings() + { + var vm = _serviceProvider.GetRequiredService(); + var dialog = new Views.SettingsDialog(vm) { Owner = Application.Current.MainWindow }; + if (dialog.ShowDialog() == true) + { + // Au retour, l'URL serveur ou l'installRoot ont pu changer → refresh + _license = _licenseService.GetCached(); + OnPropertyChanged(nameof(LicenseSummary)); + RebuildList(); + } + } + [RelayCommand] private async Task ActivateLicenseAsync() { @@ -339,6 +361,9 @@ public sealed partial class MainViewModel : ObservableObject ProgressDetail = null; ProgressPercent = 0; RebuildList(); + _toastService.ShowSuccess( + "Installation terminée", + $"Proserve v{row.Version} est prête à être lancée."); } catch (OperationCanceledException) { @@ -357,6 +382,7 @@ public sealed partial class MainViewModel : ObservableObject $"Le téléchargement ou l'installation a échoué :\n\n{ex.Message}", "Erreur", MessageBoxButton.OK, MessageBoxImage.Error); StatusMessage = $"Erreur : {ex.Message}"; + _toastService.ShowError($"Échec v{row.Version}", ex.Message); } finally { diff --git a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..9f189d9 --- /dev/null +++ b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs @@ -0,0 +1,203 @@ +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Reflection; +using System.Windows; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using PSLauncher.Core.Configuration; +using PSLauncher.Core.Downloads; +using PSLauncher.Core.Licensing; +using PSLauncher.Models; + +namespace PSLauncher.App.ViewModels; + +public sealed partial class SettingsViewModel : ObservableObject +{ + private readonly IConfigStore _configStore; + private readonly LocalConfig _config; + private readonly ILicenseService _licenseService; + private readonly IDownloadStateStore _downloadStore; + private readonly HttpClient _http; + private readonly ILogger _logger; + + public string LauncherVersion { get; } + public string MachineId { get; } + public string LogsDirectory { get; } + public string CacheDirectory { get; } + + [ObservableProperty] private string _serverBaseUrl; + [ObservableProperty] private string _installRoot; + [ObservableProperty] private string? _connectionStatus; + [ObservableProperty] private bool _isTestingConnection; + [ObservableProperty] private string _cacheSizeDisplay = "—"; + + public string LicenseInfo + { + get + { + var l = _licenseService.GetCached(); + if (l is null) return "Aucune license activée"; + return $"{l.Status} • {l.OwnerName} • exp. {l.DownloadEntitlementUntil:dd/MM/yyyy}"; + } + } + + public SettingsViewModel( + IConfigStore configStore, + LocalConfig config, + ILicenseService licenseService, + IDownloadStateStore downloadStore, + HttpClient http, + ILogger logger) + { + _configStore = configStore; + _config = config; + _licenseService = licenseService; + _downloadStore = downloadStore; + _http = http; + _logger = logger; + + _serverBaseUrl = config.ServerBaseUrl; + _installRoot = config.InstallRoot; + + LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; + MachineId = licenseService.GetMachineId(); + LogsDirectory = App.LogsDirectory; + CacheDirectory = downloadStore.GetDownloadsDirectory(); + RefreshCacheSize(); + } + + [RelayCommand] + private async Task TestConnectionAsync() + { + ConnectionStatus = "Test en cours…"; + IsTestingConnection = true; + try + { + var url = ServerBaseUrl.TrimEnd('/') + "/health"; + using var resp = await _http.GetAsync(url); + if (resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(); + ConnectionStatus = $"✓ OK ({(int)resp.StatusCode}) — {body.Substring(0, Math.Min(80, body.Length))}"; + } + else + { + ConnectionStatus = $"✗ HTTP {(int)resp.StatusCode}"; + } + } + catch (Exception ex) + { + ConnectionStatus = $"✗ Erreur : {ex.Message}"; + } + finally + { + IsTestingConnection = false; + } + } + + [RelayCommand] + private void BrowseInstallRoot() + { + var dlg = new Microsoft.Win32.OpenFolderDialog + { + Title = "Choisir le dossier d'installation", + InitialDirectory = Directory.Exists(InstallRoot) ? InstallRoot : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + }; + if (dlg.ShowDialog() == true) + { + InstallRoot = dlg.FolderName; + } + } + + [RelayCommand] + private void OpenLogsFolder() + { + if (Directory.Exists(LogsDirectory)) + Process.Start(new ProcessStartInfo { FileName = LogsDirectory, UseShellExecute = true, Verb = "open" }); + } + + [RelayCommand] + private void OpenCacheFolder() + { + if (Directory.Exists(CacheDirectory)) + Process.Start(new ProcessStartInfo { FileName = CacheDirectory, UseShellExecute = true, Verb = "open" }); + } + + [RelayCommand] + private void ClearCache() + { + var confirm = MessageBox.Show( + "Vider le cache de téléchargements ? Les téléchargements en cours/pause seront perdus.", + "Confirmer", + MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); + if (confirm != MessageBoxResult.Yes) return; + + try + { + foreach (var f in Directory.EnumerateFiles(CacheDirectory)) + { + try { File.Delete(f); } catch (Exception ex) { _logger.LogWarning(ex, "Cannot delete {F}", f); } + } + RefreshCacheSize(); + } + catch (Exception ex) + { + _logger.LogError(ex, "ClearCache failed"); + MessageBox.Show("Échec : " + ex.Message, "Erreur", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + [RelayCommand] + private void CopyMachineId() + { + try { Clipboard.SetText(MachineId); } + catch (Exception ex) { _logger.LogWarning(ex, "Clipboard failed"); } + } + + [RelayCommand] + private void Save() + { + _config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/'); + _config.InstallRoot = InstallRoot; + _configStore.Save(_config); + _logger.LogInformation("Settings saved"); + } + + [RelayCommand] + private void DeactivateLicense() + { + var confirm = MessageBox.Show( + "Désactiver la license sur cette machine ?\nTu pourras la réactiver avec ta clé.", + "Confirmer", + MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No); + if (confirm != MessageBoxResult.Yes) return; + _licenseService.Clear(); + OnPropertyChanged(nameof(LicenseInfo)); + } + + private void RefreshCacheSize() + { + try + { + long total = 0; + if (Directory.Exists(CacheDirectory)) + { + foreach (var f in Directory.EnumerateFiles(CacheDirectory)) + total += new FileInfo(f).Length; + } + CacheSizeDisplay = FormatSize(total); + } + catch { CacheSizeDisplay = "—"; } + } + + private static string FormatSize(long bytes) + { + if (bytes <= 0) return "0 o"; + string[] units = { "o", "Ko", "Mo", "Go", "To" }; + double v = bytes; int u = 0; + while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; } + return $"{v:0.#} {units[u]}"; + } +} diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml index 9609387..4813ff5 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -175,7 +175,11 @@ VerticalAlignment="Center" Margin="0,0,8,0" />