feat: sync guard - Blockieren alter Clients beim sync

This commit is contained in:
2026-08-20 23:54:04 +02:00
parent 7b883555bf
commit 59bd8abb45
19 changed files with 369 additions and 26 deletions
+3 -1
View File
@@ -253,7 +253,9 @@ public static class AppBootstrapper
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<DashboardViewModel>();
services.AddSingleton(sp =>
new SyncStatusViewModel(sp.GetService<SyncEngine>()));
new SyncStatusViewModel(
sp.GetService<SyncEngine>(),
sp.GetRequiredService<NotificationService>()));
services.AddSingleton<GroupListViewModel>();
services.AddSingleton<StudentListViewModel>();
services.AddSingleton<TimetableViewModel>();
@@ -1,11 +1,12 @@
using System.Net;
using System.Net.Http.Json;
using LehrerApp.Sync;
namespace LehrerApp.Desktop.Services;
public class SyncAuthException(string userMessage) : Exception(userMessage);
public enum SyncConnectionTestResult { Ok, Unauthorized, Unreachable }
public enum SyncConnectionTestResult { Ok, Unauthorized, IncompatibleVersion, Unreachable }
/// <summary>
/// Login/Verbindungstest gegen den eigenen Sync-Server (LehrerApp.Api) — getrennt vom bereits
@@ -27,7 +28,10 @@ public class SyncAuthService(HttpClient http)
HttpResponseMessage resp;
try
{
resp = await http.PostAsJsonAsync(CombineUrl(serverUrl, "/api/auth/login"), new { username, password });
using var req = SyncProtocol.CreateRequest(HttpMethod.Post,
CombineUrl(serverUrl, "/api/auth/login"));
req.Content = JsonContent.Create(new { username, password });
resp = await http.SendAsync(req);
}
catch (HttpRequestException)
{
@@ -49,6 +53,7 @@ public class SyncAuthService(HttpClient http)
public async Task<SyncConnectionTestResult> TestConnectionAsync(string serverUrl, string? token)
{
using var req = new HttpRequestMessage(HttpMethod.Get, CombineUrl(serverUrl, "/api/sync/status"));
req.Headers.Add(SyncProtocol.VersionHeaderName, SyncProtocol.CurrentVersion);
if (token is not null)
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
@@ -56,6 +61,8 @@ public class SyncAuthService(HttpClient http)
{
var resp = await http.SendAsync(req);
if (resp.StatusCode == HttpStatusCode.Unauthorized) return SyncConnectionTestResult.Unauthorized;
if (resp.StatusCode == HttpStatusCode.UpgradeRequired)
return SyncConnectionTestResult.IncompatibleVersion;
return resp.IsSuccessStatusCode ? SyncConnectionTestResult.Ok : SyncConnectionTestResult.Unreachable;
}
catch (HttpRequestException) { return SyncConnectionTestResult.Unreachable; }
@@ -512,6 +512,9 @@ public partial class SettingsViewModel : ObservableObject
{
SyncConnectionTestResult.Ok => "Verbindung erfolgreich.",
SyncConnectionTestResult.Unauthorized => "Server erreichbar, aber nicht angemeldet oder Anmeldung abgelaufen.",
SyncConnectionTestResult.IncompatibleVersion =>
"Diese App-Version ist veraltet oder nicht mehr mit dem Sync-Server kompatibel. " +
"Bitte aktualisiere die LehrerApp.",
_ => "Server nicht erreichbar. Bitte Adresse und Internetverbindung prüfen.",
};
}
@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Desktop.Services;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
@@ -8,11 +9,19 @@ namespace LehrerApp.Desktop.ViewModels;
public partial class SyncStatusViewModel : ObservableObject
{
private readonly SyncEngine? _engine;
private readonly NotificationService? _notifications;
private bool _incompatibleNotificationShown;
public const string IncompatibleVersionMessage =
"Diese App-Version ist veraltet oder nicht mit dem Sync-Server kompatibel. " +
"Bitte aktualisiere die LehrerApp. Die Synchronisierung wurde zum Schutz deiner Daten angehalten.";
[ObservableProperty] private string _statusText = "Kein Server konfiguriert";
[ObservableProperty] private string _lastSyncText = "";
[ObservableProperty] private bool _isSyncing;
[ObservableProperty] private bool _isServerConfigured;
[ObservableProperty] private bool _isIncompatibleVersion;
[ObservableProperty] private bool _canAttemptSync;
[ObservableProperty] private int _pendingCount;
/// <summary>Feuert, wenn ein Sync tatsächlich Daten angewendet hat (siehe SyncEngine.
@@ -21,10 +30,12 @@ public partial class SyncStatusViewModel : ObservableObject
/// schreibt.</summary>
public event Action? DataChanged;
public SyncStatusViewModel(SyncEngine? engine)
public SyncStatusViewModel(SyncEngine? engine, NotificationService? notifications = null)
{
_engine = engine;
_notifications = notifications;
IsServerConfigured = engine is not null;
CanAttemptSync = IsServerConfigured;
if (_engine is not null)
{
_engine.StatusChanged += OnStatus;
@@ -41,6 +52,8 @@ public partial class SyncStatusViewModel : ObservableObject
private void OnStatus(SyncStatus s)
{
IsSyncing = s.State == SyncState.Syncing;
IsIncompatibleVersion = s.State == SyncState.IncompatibleVersion;
CanAttemptSync = IsServerConfigured && !IsIncompatibleVersion;
PendingCount = s.PendingEvents;
StatusText = s.State switch
{
@@ -48,9 +61,16 @@ public partial class SyncStatusViewModel : ObservableObject
SyncState.Syncing => "Synchronisiere…",
SyncState.Offline => "Offline",
SyncState.Error => $"Fehler: {s.ErrorMessage}",
SyncState.IncompatibleVersion => "Update erforderlich",
_ => "",
};
LastSyncText = s.LastSyncAt.HasValue ? $"Zuletzt: {s.LastSyncAt:HH:mm}" : "Noch nie";
if (IsIncompatibleVersion && !_incompatibleNotificationShown)
{
_incompatibleNotificationShown = true;
_notifications?.ShowError(IncompatibleVersionMessage);
}
}
[RelayCommand(CanExecute = nameof(CanSync))]
+21 -2
View File
@@ -30,8 +30,7 @@
<DrawerPage x:Name="RootDrawer"
DrawerLength="220"
DrawerBehavior="Auto"
DrawerLayoutBehavior="CompactInline"
Content="{Binding CurrentPage}">
DrawerLayoutBehavior="CompactInline">
<DrawerPage.DataTemplates>
<DataTemplate DataType="vm:DashboardViewModel">
@@ -63,6 +62,26 @@
</DataTemplate>
</DrawerPage.DataTemplates>
<DrawerPage.Content>
<DockPanel>
<Border DockPanel.Dock="Top"
IsVisible="{Binding SyncStatus.IsIncompatibleVersion}"
Background="#B3261E" Padding="16,12">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="⚠" FontSize="18" Foreground="White"
VerticalAlignment="Center"/>
<StackPanel>
<TextBlock Text="Software-Update erforderlich"
Foreground="White" FontWeight="SemiBold"/>
<TextBlock Text="{x:Static vm:SyncStatusViewModel.IncompatibleVersionMessage}"
Foreground="White" Opacity="0.92" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</Border>
<ContentControl Content="{Binding CurrentPage}"/>
</DockPanel>
</DrawerPage.Content>
<DrawerPage.Drawer>
<DockPanel Classes.compact="{Binding !#RootDrawer.IsOpen}">
<DockPanel.Styles>
+7 -2
View File
@@ -4,14 +4,19 @@
x:Class="LehrerApp.Desktop.Views.SyncStatusBar"
x:DataType="vm:SyncStatusViewModel">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<StackPanel Grid.Column="0" IsVisible="{Binding !IsIncompatibleVersion}">
<TextBlock Text="{Binding StatusText}" FontSize="12" Opacity="0.7"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding LastSyncText}" FontSize="10" Opacity="0.4"/>
</StackPanel>
<StackPanel Grid.Column="0" IsVisible="{Binding IsIncompatibleVersion}">
<TextBlock Text="⚠ Update erforderlich" FontSize="12" FontWeight="SemiBold"
Foreground="#D32F2F"/>
<TextBlock Text="Sync angehalten" FontSize="10" Foreground="#D32F2F" Opacity="0.8"/>
</StackPanel>
<Button Grid.Column="1" Content="↻" FontSize="14"
Command="{Binding SyncNowCommand}"
IsVisible="{Binding IsServerConfigured}"
IsVisible="{Binding CanAttemptSync}"
Background="Transparent" Padding="6,4"
ToolTip.Tip="Jetzt synchronisieren"/>
</Grid>