From f2cb3c98c68e867c4ed5e0fef60667e1a887e988 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Tue, 18 Aug 2026 16:35:11 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Ger=C3=A4te-Pairing=20-=20Fehlschlag=20b?= =?UTF-8?q?eim=20Einl=C3=B6sen=20war=20unsichtbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RedeemPairingCode() rief nach einem Fehlschlag sofort RestartApplication() auf, direkt nach dem Setzen von PairingStatus - die Fehlermeldung konnte nie gerendert werden, und wurde auch nirgends geloggt. Ein Fehlschlag (falscher/abgelaufener Code, Server nicht erreichbar) wirkte dadurch wie ein kommentarloser Absturz ohne jede Spur. - SettingsViewModel loggt den Fehler jetzt über AppLogger und zeigt vor dem Neustart einen Bestätigungsdialog mit der echten Fehlermeldung an. - AppBootstrapper.RestartApplication() gibt unter dotnet run/IDE-Debug (wo ProcessPath auf den dotnet-Host statt die App zeigt) die ursprünglichen Kommandozeilenargumente beim Neustart mit, statt nur die dotnet-CLI-Hilfe zu öffnen. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Desktop.Tests/Fakes.cs | 8 ++++++++ .../SettingsViewModelTests.cs | 11 +++++++---- LehrerApp.Desktop/AppBootstrapper.cs | 12 +++++++++++- .../ViewModels/Settings/SettingsViewModel.cs | 15 ++++++++++++++- .../Views/Settings/SettingsView.axaml.cs | 14 ++++++++++++++ TODO.md | 17 +++++++++++++++++ 6 files changed, 71 insertions(+), 6 deletions(-) diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index a8594b0..b750865 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -56,6 +56,14 @@ public static class TestSupport Directory.CreateDirectory(tempPath); return new PeriodScheduleService(tempPath); } + + /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. + public static AppLogger BuildAppLogger() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-applogger-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new AppLogger(tempPath); + } } public class FakeStudents(List all) : IStudentRepository diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index ba1205e..7dcd607 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -32,7 +32,7 @@ public sealed class SettingsViewModelTests new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), - eventQueue ?? TestSupport.BuildEventQueue()); + eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger()); } [Fact] @@ -148,7 +148,8 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), - TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue()); + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), + TestSupport.BuildAppLogger()); vm.SelectedStateName = "Bayern"; @@ -171,7 +172,8 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), - TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue()); + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), + TestSupport.BuildAppLogger()); vm.PeriodTimes[0].StartText = "08:00"; vm.PeriodTimes[0].EndText = "08:45"; @@ -198,7 +200,8 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), - TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue()); + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), + TestSupport.BuildAppLogger()); vm.PeriodTimes[0].StartText = "08:45"; vm.PeriodTimes[0].EndText = "08:00"; diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 60097a5..ae1ae0b 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -82,7 +82,17 @@ public static class AppBootstrapper { var exePath = Environment.ProcessPath; if (!string.IsNullOrEmpty(exePath)) - System.Diagnostics.Process.Start(exePath); + { + var psi = new System.Diagnostics.ProcessStartInfo(exePath) { UseShellExecute = false }; + // Unter "dotnet run"/IDE-Debug zeigt ProcessPath auf den SDK-Host (dotnet[.exe]) statt + // auf die App selbst - ein Neustart ohne Argumente würde nur die dotnet-CLI-Hilfe + // anzeigen statt die App neu zu starten. Die ursprünglichen Kommandozeilenargumente + // (u.a. der DLL-Pfad) erneut mitgeben deckt auch diesen Fall ab. + if (Path.GetFileNameWithoutExtension(exePath).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) + foreach (var arg in Environment.GetCommandLineArgs()) + psi.ArgumentList.Add(arg); + System.Diagnostics.Process.Start(psi); + } Environment.Exit(0); } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 92a6b39..182a976 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -212,6 +212,12 @@ public partial class SettingsViewModel : ObservableObject /// den Stand des anderen Geräts ersetzt wird. public Func>? OnConfirmPairingRestore { get; set; } + /// Vom Code-Behind gesetzt: zeigt einen Fehlerdialog, den der Nutzer aktiv wegklicken muss — + /// notwendig, weil RedeemPairingCode() nach einem Fehlschlag sofort neu startet (_dbContext ist + /// zu dem Zeitpunkt bereits disposed, siehe dort) und PairingStatus dadurch sonst nie sichtbar + /// gerendert würde. + public Func? OnShowPairingError { get; set; } + // ── Konstruktor ─────────────────────────────────────────────────────────── private readonly ISchoolHolidayRepository _schoolHolidays; @@ -225,6 +231,7 @@ public partial class SettingsViewModel : ObservableObject private readonly EventQueue _eventQueue; private readonly SnapshotService? _snapshotService; private readonly CompetencyCatalogImportService _catalogImport; + private readonly AppLogger _logger; public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, @@ -236,8 +243,9 @@ public partial class SettingsViewModel : ObservableObject ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, AiSettingsService aiSettings, AiPlanningService aiPlanning, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, - SnapshotService? snapshotService = null) + AppLogger logger, SnapshotService? snapshotService = null) { + _logger = logger; _subjects = subjects; _domainRepo = domainRepo; _gradingKeyTemplates = gradingKeyTemplates; @@ -564,6 +572,11 @@ public partial class SettingsViewModel : ObservableObject // _dbContext ist bereits disposed, PairingStatus ist aber ein reines ViewModel-Feld // ohne DB-Zugriff - das Setzen ist unabhängig davon noch sicher. PairingStatus = $"Fehlgeschlagen: {ex.Message}"; + _logger.Error("Geräte-Pairing (Redeem) fehlgeschlagen", ex); + // Ohne diesen Dialog würde PairingStatus nie gerendert: der direkt folgende Neustart + // (unten, auch im Fehlerfall nötig, siehe Kommentar oben) beendet den Prozess, bevor + // Avalonia den nächsten Frame zeichnen könnte. + if (OnShowPairingError is not null) await OnShowPairingError(ex.Message); } AppBootstrapper.RestartApplication(); } diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs index 42e3ad7..563f4a0 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs @@ -21,6 +21,7 @@ public partial class SettingsView : UserControl vm.OnAppLockChanged = () => App.Services.GetRequiredService().ApplyConfig(); vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog; vm.OnConfirmPairingRestore = ShowPairingRestoreConfirmDialog; + vm.OnShowPairingError = ShowPairingErrorDialog; } } @@ -66,6 +67,19 @@ public partial class SettingsView : UserControl return owner is not null && await dialog.ShowDialog(owner); } + private async Task ShowPairingErrorDialog(string message) + { + var info = new ConfirmDialogInfo + { + Title = "Kopplung fehlgeschlagen", + Message = $"{message}\n\nDie App wird jetzt neu gestartet (unveränderte Datenbank).", + ConfirmText = "OK", + }; + var dialog = new ConfirmDialog { DataContext = info }; + var owner = TopLevel.GetTopLevel(this) as Window; + if (owner is not null) await dialog.ShowDialog(owner); + } + private async void OnImportClick(object? sender, RoutedEventArgs e) { var topLevel = TopLevel.GetTopLevel(this); diff --git a/TODO.md b/TODO.md index 35fd703..654ad54 100644 --- a/TODO.md +++ b/TODO.md @@ -1533,6 +1533,23 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba Einlösen (überschreibt die lokale Datenbank vollständig, alter Stand wird automatisch als Backup gesichert). Kein QR-Code (nur Passphrase-Code) — für zwei eigene Geräte per Hand abtippen ausreichend, QR-Code wäre erst für eine Companion-App relevant. + + **Nachtrag (Bugfix unsichtbarer Fehlschlag beim Einlösen):** Nutzer-Bug-Report — beim + Einlösen eines Codes auf dem zweiten Gerät schloss sich die App kommentarlos, nach dem + Neustart war der alte Datenbestand weiterhin da. Ursache: `RedeemPairingCode()` ruft + `AppBootstrapper.RestartApplication()` bewusst in JEDEM Fall auf (auch bei Fehlschlag, siehe + Kommentar dort — `_dbContext` ist zu dem Zeitpunkt bereits disposed), aber direkt im Anschluss + an das Setzen von `PairingStatus`, ohne dass Avalonia je einen Frame damit rendern konnte — + ein Fehlschlag (falscher/abgelaufener Code, Server nicht erreichbar) war dadurch komplett + unsichtbar, zusätzlich wurde die Exception nirgends geloggt. Behoben: `SettingsViewModel` + bekommt jetzt `AppLogger` injiziert und protokolliert den Fehler; ein neuer, vom Code-Behind + gesetzter `OnShowPairingError`-Callback zeigt vor dem Neustart einen `ConfirmDialog`, den der + Nutzer aktiv wegklicken muss — das garantiert, dass die Fehlermeldung tatsächlich gelesen + werden kann, bevor der Prozess beendet wird. Zusätzlich `AppBootstrapper.RestartApplication()` + robuster gemacht: unter `dotnet run`/IDE-Debug zeigt `Environment.ProcessPath` auf den + SDK-Host (`dotnet`/`dotnet.exe`) statt auf die App selbst, ein Neustart ohne Argumente hätte + dort nur die dotnet-CLI-Hilfe gezeigt statt die App neu zu starten — die ursprünglichen + Kommandozeilenargumente werden in diesem Fall jetzt erneut mitgegeben. - [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel. - [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen — personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus