fix: Geräte-Pairing - Fehlschlag beim Einlösen war unsichtbar

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 16:35:11 +02:00
co-authored by Claude Sonnet 5
parent d59d658bba
commit f2cb3c98c6
6 changed files with 71 additions and 6 deletions
+8
View File
@@ -56,6 +56,14 @@ public static class TestSupport
Directory.CreateDirectory(tempPath); Directory.CreateDirectory(tempPath);
return new PeriodScheduleService(tempPath); return new PeriodScheduleService(tempPath);
} }
/// Analog zu <see cref="BuildAiSettingsService"/>, 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<Student> all) : IStudentRepository public class FakeStudents(List<Student> all) : IStudentRepository
@@ -32,7 +32,7 @@ public sealed class SettingsViewModelTests
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
eventQueue ?? TestSupport.BuildEventQueue()); eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger());
} }
[Fact] [Fact]
@@ -148,7 +148,8 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), 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"; vm.SelectedStateName = "Bayern";
@@ -171,7 +172,8 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), 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].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45"; vm.PeriodTimes[0].EndText = "08:45";
@@ -198,7 +200,8 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), 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].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00"; vm.PeriodTimes[0].EndText = "08:00";
+11 -1
View File
@@ -82,7 +82,17 @@ public static class AppBootstrapper
{ {
var exePath = Environment.ProcessPath; var exePath = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exePath)) 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); Environment.Exit(0);
} }
@@ -212,6 +212,12 @@ public partial class SettingsViewModel : ObservableObject
/// den Stand des anderen Geräts ersetzt wird. /// den Stand des anderen Geräts ersetzt wird.
public Func<Task<bool>>? OnConfirmPairingRestore { get; set; } public Func<Task<bool>>? 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<string, Task>? OnShowPairingError { get; set; }
// ── Konstruktor ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays; private readonly ISchoolHolidayRepository _schoolHolidays;
@@ -225,6 +231,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly EventQueue _eventQueue; private readonly EventQueue _eventQueue;
private readonly SnapshotService? _snapshotService; private readonly SnapshotService? _snapshotService;
private readonly CompetencyCatalogImportService _catalogImport; private readonly CompetencyCatalogImportService _catalogImport;
private readonly AppLogger _logger;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
@@ -236,8 +243,9 @@ public partial class SettingsViewModel : ObservableObject
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
SnapshotService? snapshotService = null) AppLogger logger, SnapshotService? snapshotService = null)
{ {
_logger = logger;
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
_gradingKeyTemplates = gradingKeyTemplates; _gradingKeyTemplates = gradingKeyTemplates;
@@ -564,6 +572,11 @@ public partial class SettingsViewModel : ObservableObject
// _dbContext ist bereits disposed, PairingStatus ist aber ein reines ViewModel-Feld // _dbContext ist bereits disposed, PairingStatus ist aber ein reines ViewModel-Feld
// ohne DB-Zugriff - das Setzen ist unabhängig davon noch sicher. // ohne DB-Zugriff - das Setzen ist unabhängig davon noch sicher.
PairingStatus = $"Fehlgeschlagen: {ex.Message}"; 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(); AppBootstrapper.RestartApplication();
} }
@@ -21,6 +21,7 @@ public partial class SettingsView : UserControl
vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig(); vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig();
vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog; vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog;
vm.OnConfirmPairingRestore = ShowPairingRestoreConfirmDialog; vm.OnConfirmPairingRestore = ShowPairingRestoreConfirmDialog;
vm.OnShowPairingError = ShowPairingErrorDialog;
} }
} }
@@ -66,6 +67,19 @@ public partial class SettingsView : UserControl
return owner is not null && await dialog.ShowDialog<bool>(owner); return owner is not null && await dialog.ShowDialog<bool>(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<bool>(owner);
}
private async void OnImportClick(object? sender, RoutedEventArgs e) private async void OnImportClick(object? sender, RoutedEventArgs e)
{ {
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
+17
View File
@@ -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 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 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. 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. - [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
- [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen — - [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 personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus