From 2a32034b63b5112521f40ffa617d5bb9a6d64ba2 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Fri, 16 Oct 2026 06:43:05 +0200 Subject: [PATCH] Einstellungen-Fenster: Erscheinungsbild, Update-Pruefung, Professioneller Netzwerker, Testumgebung, Backup-Ordner/-Passwort (#48) - AppSettings/AppSettingsStore (JSON unter %AppData%), Backup-Passwort im Anmeldeinformationsspeicher; Speicherfehler wird angezeigt statt still ignoriert - Hell/Dunkel/Wie System, System-Watcher nur bei 'Wie System' - Experten-Modus im Assistenten nur mit 'Professioneller Netzwerker' (Mac v1.3.0) - Update-Pruefung beim Verbinden abschaltbar Co-Authored-By: Claude Opus 5.5 --- src/MikrotikInstaller.App/App.xaml.cs | 7 +- src/MikrotikInstaller.App/MainWindow.xaml | 8 ++ src/MikrotikInstaller.App/MainWindow.xaml.cs | 17 +++- src/MikrotikInstaller.App/UserSettings.cs | 57 ++++++++++++ .../ViewModels/SettingsViewModel.cs | 92 +++++++++++++++++++ .../Steps/DeviceOverviewStepViewModel.cs | 5 +- .../Steps/ModeSelectionStepViewModel.cs | 3 +- .../ViewModels/WizardSession.cs | 9 +- .../Views/SettingsWindow.xaml | 86 +++++++++++++++++ .../Views/SettingsWindow.xaml.cs | 16 ++++ .../Settings/AppSettingsStoreTests.cs | 47 ++++++++++ .../Settings/AppSettings.cs | 75 +++++++++++++++ 12 files changed, 412 insertions(+), 10 deletions(-) create mode 100644 src/MikrotikInstaller.App/UserSettings.cs create mode 100644 src/MikrotikInstaller.App/ViewModels/SettingsViewModel.cs create mode 100644 src/MikrotikInstaller.App/Views/SettingsWindow.xaml create mode 100644 src/MikrotikInstaller.App/Views/SettingsWindow.xaml.cs create mode 100644 src/MikrotikInstaller.Core.Tests/Settings/AppSettingsStoreTests.cs create mode 100644 src/MikrotikInstaller.Core/Settings/AppSettings.cs diff --git a/src/MikrotikInstaller.App/App.xaml.cs b/src/MikrotikInstaller.App/App.xaml.cs index 53c9860..dbba879 100644 --- a/src/MikrotikInstaller.App/App.xaml.cs +++ b/src/MikrotikInstaller.App/App.xaml.cs @@ -14,11 +14,8 @@ public partial class App : Application { base.OnStartup(e); - // Übernimmt Hell/Dunkel vom Windows-Systemthema; die Akzentfarbe setzen wir - // anschließend selbst auf MikroTik-Grün statt auf die Windows-Akzentfarbe. - ApplicationThemeManager.ApplySystemTheme(updateAccent: false); - var appliedTheme = ApplicationThemeManager.GetAppTheme(); - ApplicationAccentColorManager.Apply(BrandAccent, appliedTheme, systemGlassColor: false, systemAccentColor: false); + // Hell/Dunkel laut Einstellungen (Standard: wie Windows); die Akzentfarbe ist immer MikroTik-Grün. + UserSettings.ApplyTheme(); DispatcherUnhandledException += OnDispatcherUnhandledException; } diff --git a/src/MikrotikInstaller.App/MainWindow.xaml b/src/MikrotikInstaller.App/MainWindow.xaml index 0940189..dfb1bb2 100644 --- a/src/MikrotikInstaller.App/MainWindow.xaml +++ b/src/MikrotikInstaller.App/MainWindow.xaml @@ -44,6 +44,7 @@ + @@ -189,6 +190,13 @@ + + + + + + diff --git a/src/MikrotikInstaller.App/MainWindow.xaml.cs b/src/MikrotikInstaller.App/MainWindow.xaml.cs index 62ff983..870945a 100644 --- a/src/MikrotikInstaller.App/MainWindow.xaml.cs +++ b/src/MikrotikInstaller.App/MainWindow.xaml.cs @@ -16,11 +16,24 @@ public partial class MainWindow : FluentWindow viewModel.CloseRequested += (_, _) => Close(); } - // Folgt Hell/Dunkel-Wechseln des Windows-Systemthemes zur Laufzeit; + // Folgt Hell/Dunkel-Wechseln des Windows-Systemthemes zur Laufzeit — nur bei "Wie System"; // updateAccents:false, damit unser MikroTik-Grün-Akzent dabei erhalten bleibt. - SystemThemeWatcher.Watch(this, WindowBackdropType.Mica, updateAccents: false); + WatchSystemThemeIfWanted(); + UserSettings.Changed += (_, _) => WatchSystemThemeIfWanted(); } + private void WatchSystemThemeIfWanted() + { + SystemThemeWatcher.UnWatch(this); + if (UserSettings.Current.Theme == Core.Settings.AppTheme.System) + { + SystemThemeWatcher.Watch(this, WindowBackdropType.Mica, updateAccents: false); + } + } + + private void OnOpenSettings(object sender, System.Windows.RoutedEventArgs e) => + new Views.SettingsWindow { Owner = this }.ShowDialog(); + private async void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e) { if (DataContext is MainViewModel viewModel) diff --git a/src/MikrotikInstaller.App/UserSettings.cs b/src/MikrotikInstaller.App/UserSettings.cs new file mode 100644 index 0000000..e9e2842 --- /dev/null +++ b/src/MikrotikInstaller.App/UserSettings.cs @@ -0,0 +1,57 @@ +using System.Windows; +using MikrotikInstaller.App.Credentials; +using MikrotikInstaller.Core.Settings; +using Wpf.Ui.Appearance; + +namespace MikrotikInstaller.App; + +/// Die geladenen Einstellungen für die ganze App, plus das Backup-Passwort aus dem Anmeldeinformationsspeicher. +public static class UserSettings +{ + private const string BackupPasswordId = "backup-password"; + private static readonly AppSettingsStore Store = new(); + + public static AppSettings Current { get; private set; } = Store.Load(); + + public static event EventHandler? Changed; + + public static void Save(AppSettings settings) + { + Store.Save(settings); + Current = settings; + ApplyTheme(); + Changed?.Invoke(null, EventArgs.Empty); + } + + /// Leer/null = Backups unverschlüsselt. + public static string? BackupPassword + { + get => WindowsCredentialStore.TryRead(BackupPasswordId) is { Length: > 0 } password ? password : null; + } + + /// Gibt false zurück, wenn Windows das Passwort nicht speichern konnte (Mac v1.11.4: dann nicht als gesetzt anzeigen). + public static bool SetBackupPassword(string? password) + { + if (string.IsNullOrEmpty(password)) + { + WindowsCredentialStore.Delete(BackupPasswordId); + return true; + } + + return WindowsCredentialStore.Save(BackupPasswordId, "backup", password); + } + + public static void ApplyTheme() + { + if (Current.Theme == AppTheme.System) + { + ApplicationThemeManager.ApplySystemTheme(updateAccent: false); + } + else + { + ApplicationThemeManager.Apply(Current.Theme == AppTheme.Dark ? ApplicationTheme.Dark : ApplicationTheme.Light, updateAccent: false); + } + + ApplicationAccentColorManager.Apply(App.BrandAccent, ApplicationThemeManager.GetAppTheme(), systemGlassColor: false, systemAccentColor: false); + } +} diff --git a/src/MikrotikInstaller.App/ViewModels/SettingsViewModel.cs b/src/MikrotikInstaller.App/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..c30c51f --- /dev/null +++ b/src/MikrotikInstaller.App/ViewModels/SettingsViewModel.cs @@ -0,0 +1,92 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Win32; +using MikrotikInstaller.Core.Settings; + +namespace MikrotikInstaller.App.ViewModels; + +/// Einstellungen-Fenster (Mac: Einstellungen → Allgemein/Darstellung). Änderungen gelten erst mit „Speichern". +public partial class SettingsViewModel : ObservableObject +{ + public SettingsViewModel() + { + var settings = UserSettings.Current; + _theme = settings.Theme; + _checkUpdatesOnConnect = settings.CheckUpdatesOnConnect; + _professionalMode = settings.ProfessionalMode; + _testEnvironment = settings.TestEnvironment; + _backupFolder = settings.EffectiveBackupFolder; + _hasBackupPassword = UserSettings.BackupPassword is not null; + } + + [ObservableProperty] + private AppTheme _theme; + + [ObservableProperty] + private bool _checkUpdatesOnConnect; + + [ObservableProperty] + private bool _professionalMode; + + [ObservableProperty] + private bool _testEnvironment; + + [ObservableProperty] + private string _backupFolder; + + /// Neues Backup-Passwort; leer lassen = unverändert. + [ObservableProperty] + private string _newBackupPassword = string.Empty; + + [ObservableProperty] + private bool _hasBackupPassword; + + [ObservableProperty] + private bool _removeBackupPassword; + + [ObservableProperty] + private bool _restartIntro; + + [ObservableProperty] + private string? _errorMessage; + + /// Wird nach erfolgreichem Speichern ausgelöst, damit das Fenster sich schließt. + public event EventHandler? Saved; + + [RelayCommand] + private void ChooseBackupFolder() + { + var dialog = new OpenFolderDialog { Title = "Ordner für Backups wählen", InitialDirectory = BackupFolder }; + if (dialog.ShowDialog() == true) + { + BackupFolder = dialog.FolderName; + } + } + + [RelayCommand] + private void Save() + { + ErrorMessage = null; + if (RemoveBackupPassword) + { + UserSettings.SetBackupPassword(null); + } + else if (NewBackupPassword.Length > 0 && !UserSettings.SetBackupPassword(NewBackupPassword)) + { + ErrorMessage = "Das Backup-Passwort konnte nicht gespeichert werden. Neue Backups bleiben deshalb unverändert."; + return; + } + + var current = UserSettings.Current; + UserSettings.Save(current with + { + Theme = Theme, + CheckUpdatesOnConnect = CheckUpdatesOnConnect, + ProfessionalMode = ProfessionalMode, + TestEnvironment = TestEnvironment, + BackupFolder = BackupFolder == new AppSettings().EffectiveBackupFolder ? null : BackupFolder, + IntroCompleted = current.IntroCompleted && !RestartIntro, + }); + Saved?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/DeviceOverviewStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/DeviceOverviewStepViewModel.cs index 886900b..efbed4d 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/DeviceOverviewStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/DeviceOverviewStepViewModel.cs @@ -74,7 +74,10 @@ public partial class DeviceOverviewStepViewModel : WizardStepViewModelBase // Läuft im Hintergrund weiter, ohne die Navigation zu blockieren — ein frisches Gerät // hat an dieser Stelle im Assistenten meist noch keinen Internetzugang, die Prüfung // schlägt dann einfach fehl (kein Fehlerzustand) und wird nach dem Anwenden wiederholt. - _ = CheckForUpdatesInBackgroundAsync(_session.Client); + if (UserSettings.Current.CheckUpdatesOnConnect) + { + _ = CheckForUpdatesInBackgroundAsync(_session.Client); + } } catch (RouterOsException ex) { diff --git a/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs b/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs index 2290c9c..fc677b6 100644 --- a/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs +++ b/src/MikrotikInstaller.App/ViewModels/Steps/ModeSelectionStepViewModel.cs @@ -24,7 +24,8 @@ public partial class ModeSelectionStepViewModel : WizardStepViewModelBase [ObservableProperty] private bool _isExpertMode; - public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement; + /// Ohne „Professioneller Netzwerker" gibt es nichts zu wählen — dann immer der geführte Assistent. + public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement || !UserSettings.Current.ProfessionalMode; public override Task OnActivatedAsync() { diff --git a/src/MikrotikInstaller.App/ViewModels/WizardSession.cs b/src/MikrotikInstaller.App/ViewModels/WizardSession.cs index e0c8bae..df3436e 100644 --- a/src/MikrotikInstaller.App/ViewModels/WizardSession.cs +++ b/src/MikrotikInstaller.App/ViewModels/WizardSession.cs @@ -27,7 +27,14 @@ public sealed class WizardSession /// Ob der Nutzer im Moduswahl-Schritt den Experten-Modus gewählt hat. Steuert, ob die /// nachfolgenden Schritte zusätzliche, technischere Felder anzeigen. Standard: Laien-Modus (false). /// - public bool IsExpertMode { get; set; } + public bool IsExpertMode + { + // Ohne "Professioneller Netzwerker" (Einstellungen) gibt es keinen Experten-Modus (Mac v1.3.0). + get => _isExpertMode && UserSettings.Current.ProfessionalMode; + set => _isExpertMode = value; + } + + private bool _isExpertMode; /// /// Direkt nach dem Verbinden gewählter Bereich (siehe ). diff --git a/src/MikrotikInstaller.App/Views/SettingsWindow.xaml b/src/MikrotikInstaller.App/Views/SettingsWindow.xaml new file mode 100644 index 0000000..4db8464 --- /dev/null +++ b/src/MikrotikInstaller.App/Views/SettingsWindow.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MikrotikInstaller.App/Views/SettingsWindow.xaml.cs b/src/MikrotikInstaller.App/Views/SettingsWindow.xaml.cs new file mode 100644 index 0000000..b91f4d3 --- /dev/null +++ b/src/MikrotikInstaller.App/Views/SettingsWindow.xaml.cs @@ -0,0 +1,16 @@ +using System.Windows; +using MikrotikInstaller.App.ViewModels; +using Wpf.Ui.Controls; + +namespace MikrotikInstaller.App.Views; + +public partial class SettingsWindow : FluentWindow +{ + public SettingsWindow() + { + InitializeComponent(); + ((SettingsViewModel)DataContext).Saved += (_, _) => Close(); + } + + private void OnCancel(object sender, RoutedEventArgs e) => Close(); +} diff --git a/src/MikrotikInstaller.Core.Tests/Settings/AppSettingsStoreTests.cs b/src/MikrotikInstaller.Core.Tests/Settings/AppSettingsStoreTests.cs new file mode 100644 index 0000000..0602be5 --- /dev/null +++ b/src/MikrotikInstaller.Core.Tests/Settings/AppSettingsStoreTests.cs @@ -0,0 +1,47 @@ +using MikrotikInstaller.Core.Settings; + +namespace MikrotikInstaller.Core.Tests.Settings; + +public class AppSettingsStoreTests : IDisposable +{ + private readonly string _filePath = Path.Combine(Path.GetTempPath(), $"settings-test-{Guid.NewGuid()}.json"); + + public void Dispose() + { + if (File.Exists(_filePath)) + { + File.Delete(_filePath); + } + } + + [Fact] + public void Load_NoFile_ReturnsDefaults() + { + var settings = new AppSettingsStore(_filePath).Load(); + + Assert.Equal(AppTheme.System, settings.Theme); + Assert.True(settings.TestEnvironment); + Assert.False(settings.ProfessionalMode); + } + + [Fact] + public void SaveThenLoad_RoundTrips() + { + var store = new AppSettingsStore(_filePath); + store.Save(new AppSettings { Theme = AppTheme.Dark, ProfessionalMode = true, BackupFolder = @"D:\Sicherungen" }); + + var loaded = store.Load(); + + Assert.Equal(AppTheme.Dark, loaded.Theme); + Assert.True(loaded.ProfessionalMode); + Assert.Equal(@"D:\Sicherungen", loaded.EffectiveBackupFolder); + } + + [Fact] + public void Load_CorruptFile_ReturnsDefaults() + { + File.WriteAllText(_filePath, "{ kaputt"); + + Assert.Equal(new AppSettings(), new AppSettingsStore(_filePath).Load()); + } +} diff --git a/src/MikrotikInstaller.Core/Settings/AppSettings.cs b/src/MikrotikInstaller.Core/Settings/AppSettings.cs new file mode 100644 index 0000000..83cda00 --- /dev/null +++ b/src/MikrotikInstaller.Core/Settings/AppSettings.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikrotikInstaller.Core.Settings; + +public enum AppTheme +{ + System, + Light, + Dark, +} + +/// +/// Lokale Einstellungen der App (analog zu den Einstellungen der Mac-Version, ohne App-Store-Teil). +/// Enthält bewusst kein Backup-Passwort — das liegt wie Router-Passwörter im Windows-Anmeldeinformationsspeicher. +/// +public sealed record AppSettings +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public AppTheme Theme { get; init; } = AppTheme.System; + + /// Beim Verbinden im Hintergrund nach einer neueren RouterOS-Version suchen. + public bool CheckUpdatesOnConnect { get; init; } = true; + + /// „Professioneller Netzwerker": blendet den Experten-Modus im Assistenten (und später den Experte-Bereich) ein. + public bool ProfessionalMode { get; init; } + + /// Simulierte Testgeräte im Verbindungs-Schritt anbieten. + public bool TestEnvironment { get; init; } = true; + + /// Ordner für auf den PC geladene Backups; null = Standard (Dokumente\MikroTik-Backups). + public string? BackupFolder { get; init; } + + /// Die Einführung beim ersten Start wurde abgeschlossen oder übersprungen. + public bool IntroCompleted { get; init; } + + public string EffectiveBackupFolder => BackupFolder ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "MikroTik-Backups"); +} + +/// Liest/schreibt als JSON unter %AppData%\MikroTik-Installer\settings.json. +public sealed class AppSettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private readonly string _filePath; + + /// Nur zum Überschreiben in Tests. + public AppSettingsStore(string? filePath = null) + { + _filePath = filePath ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MikroTik-Installer", "settings.json"); + } + + /// Fehlende oder beschädigte Datei ergibt die Standardwerte — Einstellungen blockieren nie den Start. + public AppSettings Load() + { + try + { + return File.Exists(_filePath) + ? JsonSerializer.Deserialize(File.ReadAllText(_filePath), JsonOptions) ?? new AppSettings() + : new AppSettings(); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + return new AppSettings(); + } + } + + public void Save(AppSettings settings) + { + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); + File.WriteAllText(_filePath, JsonSerializer.Serialize(settings, JsonOptions)); + } +}