Einstellungen-Fenster: Erscheinungsbild, Update-Pruefung, Professioneller Netzwerker, Testumgebung, Backup-Ordner/-Passwort (#48)
Build / build (push) Canceled after 0s

- 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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-16 06:43:05 +02:00
1 parent 3346129652
commit 2a32034b63
12 files changed
+412 -10

No files matched your search

+2 -5
View File
@@ -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;
}
@@ -44,6 +44,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="4,0,0,20">
@@ -189,6 +190,13 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<ui:Button Grid.Row="3" Content="Einstellungen" Appearance="Transparent" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Margin="0,12,0,0" Click="OnOpenSettings">
<ui:Button.Icon>
<ui:SymbolIcon Symbol="Settings24" />
</ui:Button.Icon>
</ui:Button>
</Grid>
</Border>
+15 -2
View File
@@ -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)
+57
View File
@@ -0,0 +1,57 @@
using System.Windows;
using MikrotikInstaller.App.Credentials;
using MikrotikInstaller.Core.Settings;
using Wpf.Ui.Appearance;
namespace MikrotikInstaller.App;
/// <summary>Die geladenen Einstellungen für die ganze App, plus das Backup-Passwort aus dem Anmeldeinformationsspeicher.</summary>
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);
}
/// <summary>Leer/null = Backups unverschlüsselt.</summary>
public static string? BackupPassword
{
get => WindowsCredentialStore.TryRead(BackupPasswordId) is { Length: > 0 } password ? password : null;
}
/// <summary>Gibt false zurück, wenn Windows das Passwort nicht speichern konnte (Mac v1.11.4: dann nicht als gesetzt anzeigen).</summary>
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);
}
}
@@ -0,0 +1,92 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using MikrotikInstaller.Core.Settings;
namespace MikrotikInstaller.App.ViewModels;
/// <summary>Einstellungen-Fenster (Mac: Einstellungen → Allgemein/Darstellung). Änderungen gelten erst mit „Speichern".</summary>
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;
/// <summary>Neues Backup-Passwort; leer lassen = unverändert.</summary>
[ObservableProperty]
private string _newBackupPassword = string.Empty;
[ObservableProperty]
private bool _hasBackupPassword;
[ObservableProperty]
private bool _removeBackupPassword;
[ObservableProperty]
private bool _restartIntro;
[ObservableProperty]
private string? _errorMessage;
/// <summary>Wird nach erfolgreichem Speichern ausgelöst, damit das Fenster sich schließt.</summary>
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);
}
}
@@ -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)
{
@@ -24,7 +24,8 @@ public partial class ModeSelectionStepViewModel : WizardStepViewModelBase
[ObservableProperty]
private bool _isExpertMode;
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement;
/// <summary>Ohne „Professioneller Netzwerker" gibt es nichts zu wählen — dann immer der geführte Assistent.</summary>
public override bool ShouldSkip => _session.SelectedArea == AppArea.BackupManagement || !UserSettings.Current.ProfessionalMode;
public override Task OnActivatedAsync()
{
@@ -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).
/// </summary>
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;
/// <summary>
/// Direkt nach dem Verbinden gewählter Bereich (siehe <see cref="Steps.AreaSelectionStepViewModel"/>).
@@ -0,0 +1,86 @@
<ui:FluentWindow x:Class="MikrotikInstaller.App.Views.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:vm="clr-namespace:MikrotikInstaller.App.ViewModels"
xmlns:controls="clr-namespace:MikrotikInstaller.App.Views.Controls"
Title="Einstellungen" Width="560" Height="680"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica">
<Window.DataContext>
<vm:SettingsViewModel />
</Window.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="Einstellungen" ShowMinimize="False" ShowMaximize="False" />
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="28,8,28,8">
<StackPanel>
<TextBlock Text="Darstellung" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<RadioButton Content="Wie System" GroupName="Theme" Margin="0,0,16,0"
IsChecked="{Binding Theme, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=System}" />
<RadioButton Content="Hell" GroupName="Theme" Margin="0,0,16,0"
IsChecked="{Binding Theme, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Light}" />
<RadioButton Content="Dunkel" GroupName="Theme"
IsChecked="{Binding Theme, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Dark}" />
</StackPanel>
<TextBlock Text="Allgemein" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="Beim Verbinden nach RouterOS-Updates suchen" IsChecked="{Binding CheckUpdatesOnConnect}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="Professioneller Netzwerker" IsChecked="{Binding ProfessionalMode}" />
<controls:InfoHint Text="Blendet den Experten-Modus im Einrichtungs-Assistenten ein: zusätzliche, technischere RouterOS-Einstellungen in den einzelnen Schritten. Für die meisten Haushalte nicht nötig." />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="Testumgebung anbieten" IsChecked="{Binding TestEnvironment}" />
<controls:InfoHint Text="Zeigt im Verbindungs-Schritt simulierte Testgeräte, mit denen du den Assistenten ohne eigenen Router ausprobieren kannst. Änderungen bleiben nur bis zum Beenden der App erhalten." />
</StackPanel>
<CheckBox Content="Einführung beim nächsten Start erneut zeigen" IsChecked="{Binding RestartIntro}" Margin="0,0,0,20" />
<TextBlock Text="Sicherungen" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Text="Ordner für Backups auf diesem PC" Margin="0,0,0,4" />
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ui:TextBox Grid.Column="0" Text="{Binding BackupFolder, UpdateSourceTrigger=PropertyChanged}" />
<ui:Button Grid.Column="1" Content="Wählen …" Margin="8,0,0,0" Command="{Binding ChooseBackupFolderCommand}" />
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Backup-Passwort" />
<controls:InfoHint Text="Mit Passwort werden neue Backups von RouterOS verschlüsselt — auf dem Gerät und als Kopie auf diesem PC. Ohne das Passwort lassen sie sich nicht mehr zurückspielen, bewahre es also gut auf." />
</StackPanel>
<TextBlock Text="Ein Backup-Passwort ist gesetzt. Leer lassen, um es zu behalten." FontSize="12" Margin="0,0,0,4"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Visibility="{Binding HasBackupPassword, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="Kein Backup-Passwort gesetzt — neue Backups sind unverschlüsselt." FontSize="12" Margin="0,0,0,4"
Foreground="{StaticResource MikrotikWarningBrush}"
Visibility="{Binding HasBackupPassword, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Invert}" />
<ui:PasswordBox Password="{Binding NewBackupPassword, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8" />
<CheckBox Content="Backup-Passwort entfernen" IsChecked="{Binding RemoveBackupPassword}"
Visibility="{Binding HasBackupPassword, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock Text="{Binding ErrorMessage}" TextWrapping="Wrap" Foreground="{StaticResource MikrotikRedBrush}" Margin="0,12,0,0"
Visibility="{Binding ErrorMessage, Converter={StaticResource StringToVisibilityConverter}}" />
</StackPanel>
</ScrollViewer>
<Border Grid.Row="2" BorderBrush="{DynamicResource DividerStrokeColorDefaultBrush}" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="28,16">
<ui:Button Content="Abbrechen" Margin="0,0,8,0" IsCancel="True" Click="OnCancel" />
<ui:Button Content="Speichern" Appearance="Primary" IsDefault="True" Command="{Binding SaveCommand}" />
</StackPanel>
</Border>
</Grid>
</ui:FluentWindow>
@@ -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();
}
@@ -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());
}
}
@@ -0,0 +1,75 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MikrotikInstaller.Core.Settings;
public enum AppTheme
{
System,
Light,
Dark,
}
/// <summary>
/// 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.
/// </summary>
public sealed record AppSettings
{
[JsonConverter(typeof(JsonStringEnumConverter))]
public AppTheme Theme { get; init; } = AppTheme.System;
/// <summary>Beim Verbinden im Hintergrund nach einer neueren RouterOS-Version suchen.</summary>
public bool CheckUpdatesOnConnect { get; init; } = true;
/// <summary>„Professioneller Netzwerker": blendet den Experten-Modus im Assistenten (und später den Experte-Bereich) ein.</summary>
public bool ProfessionalMode { get; init; }
/// <summary>Simulierte Testgeräte im Verbindungs-Schritt anbieten.</summary>
public bool TestEnvironment { get; init; } = true;
/// <summary>Ordner für auf den PC geladene Backups; null = Standard (Dokumente\MikroTik-Backups).</summary>
public string? BackupFolder { get; init; }
/// <summary>Die Einführung beim ersten Start wurde abgeschlossen oder übersprungen.</summary>
public bool IntroCompleted { get; init; }
public string EffectiveBackupFolder => BackupFolder ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "MikroTik-Backups");
}
/// <summary>Liest/schreibt <see cref="AppSettings"/> als JSON unter %AppData%\MikroTik-Installer\settings.json.</summary>
public sealed class AppSettingsStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly string _filePath;
/// <param name="filePath">Nur zum Überschreiben in Tests.</param>
public AppSettingsStore(string? filePath = null)
{
_filePath = filePath ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MikroTik-Installer", "settings.json");
}
/// <summary>Fehlende oder beschädigte Datei ergibt die Standardwerte — Einstellungen blockieren nie den Start.</summary>
public AppSettings Load()
{
try
{
return File.Exists(_filePath)
? JsonSerializer.Deserialize<AppSettings>(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));
}
}