Vollständige Implementierung des EngineeringSync-Middleware-Tools: - Windows Service (Kestrel :5050) mit FileSystemWatcher + SignalR - WPF Tray-App mit PendingChanges- und Projektverwaltungs-Fenster - Setup-Wizard (8-Schritte-Installer) - SQLite/EF Core Datenschicht (WAL-Modus) - SHA-256-basiertes Debouncing (2s Fenster) - Backup-System mit konfigurierbarer Aufbewahrung Bugfixes & Verbesserungen: - BUG-1: AppDbContext OnConfiguring invertierte Bedingung behoben - BUG-2: Event-Handler-Leak in TrayApp (Fenster-Singleton-Pattern) - BUG-3: ProjectConfigChanged SignalR-Signal in allen CRUD-Endpoints - BUG-5: Rename-Sync löscht alte Datei im Simulations-Ordner - BUG-6: Doppeltes Dispose von SignalR verhindert - BUG-7: Registry-Deinstallation nur EngineeringSync-Eintrag entfernt - S1: Path-Traversal-Schutz via SafeCombine() im SyncManager - E1: FSW Buffer 64KB + automatischer Re-Scan bei Overflow - E2: Retry-Logik (3x) für gesperrte Dateien mit exponentiellem Backoff - E4: Channel.Writer.TryComplete() beim Shutdown - C2: HubMethodNames-Konstanten statt Magic Strings - E3: Pagination in Changes-API (page/pageSize Query-Parameter) - A1: Fire-and-Forget mit try/catch + Logging Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
11 KiB
Spec: Pro-Projekt Backup-Einstellungen
Datum: 2026-03-26 Status: Draft v3 Scope: EngineeringSync – Backup-Konfiguration pro Projekt im Setup-Wizard und TrayApp
Übersicht
Beim Sync erstellt der SyncManager aktuell immer eine .bak-Datei im gleichen Ordner wie die Zieldatei, ohne dass der User Kontrolle darüber hat. Ziel ist es, dem User pro Projekt volle Kontrolle über das Backup-Verhalten zu geben: aktivieren/deaktivieren, eigenen Backup-Ordner wählen, und eine Aufbewahrungsregel (max. N Backups pro Datei) festlegen.
Anforderungen
Funktional
- Der User kann pro Projekt Backups aktivieren oder deaktivieren.
- Bei aktivierten Backups kann der User wählen zwischen:
- Gleicher Ordner wie die Zieldatei (bisheriges Verhalten)
- Eigener Backup-Ordner (frei wählbarer Pfad)
- Der User kann eine Aufbewahrungsregel festlegen:
0= unbegrenzt (alle Backups behalten)N > 0= maximal N Backups pro Datei; älteste werden automatisch gelöscht
- Die Einstellungen sind konfigurierbar:
- Im Setup-Wizard (neue Seite zwischen „Erstes Projekt" und „Optionen")
- Im TrayApp
ProjectManagementWindow(neue BACKUP-Sektion beim Bearbeiten)
Nicht-funktional
- Standardwerte:
BackupEnabled = true,BackupPath = null(gleicher Ordner),MaxBackupsPerFile = 0 - Rückwärtskompatibilität: bestehende Projekte erhalten die Standardwerte per DB-Migration
- Backup-Dateien folgen weiterhin dem Schema:
{name}_{yyyyMMdd_HHmmss}{ext}.bak - Der Timestamp im Backup-Dateinamen verwendet
DateTime.Now(Ortszeit), um für den User lesbar zu sein. Dies ist eine bewusste Abweichung von der UTC-Konvention im Rest der Anwendung.
Architektur
Ausgewählter Ansatz: Backup-Felder direkt in ProjectConfig
Drei neue Felder in der bestehenden ProjectConfig-Entity – keine neue Tabelle. Konsistent mit dem Muster des Projekts (alle Projekteinstellungen zentral in einer Entity).
Verworfene Alternativen:
- Separate
ProjectBackupSettings-Entity (1:1): Mehr Boilerplate für nur 3 Felder - JSON-Column: Verlust von EF-Typsicherheit, Overkill für 3 Felder
Komponenten & Änderungen
1. Domain – ProjectConfig
public bool BackupEnabled { get; set; } = true;
public string? BackupPath { get; set; } = null; // null = gleicher Ordner wie Zieldatei
public int MaxBackupsPerFile { get; set; } = 0; // 0 = unbegrenzt
2. Infrastructure – EF Migration
- Migration:
AddBackupSettingsToProjectConfig - Standardwerte per
HasDefaultValue()inOnModelCreatingfür bestehende Rows:BackupEnabled:trueBackupPath:nullMaxBackupsPerFile:0
3. Service – SyncManager
BackupFile() wird von static zur Instanzmethode und erhält project als zweiten Parameter: BackupFile(string targetPath, ProjectConfig project).
Ablauf in BackupFile(targetPath, project):
if (!project.BackupEnabled) return;— kein Backup, sofort zurückbackupDir = project.BackupPath ?? Path.GetDirectoryName(targetPath)Directory.CreateDirectory(backupDir)— nur Backup-Verzeichnis (unabhängig vom Ziel-Verzeichnis)- Backup-Dateiname:
{name}_{DateTime.Now:yyyyMMdd_HHmmss}{ext}.bak - Backup-Datei verschieben (laufwerk-übergreifend sicher):
- Versuch:
File.Move(targetPath, backupPath)(schnell, gleiche Volume) - Falls
IOException(z.B. andere Volume, Netzlaufwerk):File.Copy+File.Deleteals Fallback
- Versuch:
if (project.MaxBackupsPerFile > 0) CleanupOldBackups(backupDir, baseName, ext, project.MaxBackupsPerFile)
ProcessChangeAsync(change, ct) – beide Call-Sites werden aktualisiert:
Die Methode hat zwei Stellen, an denen BackupFile aufgerufen wird:
// Stelle 1: Deleted-Branch (Zeile ~57)
if (File.Exists(targetPath))
{
BackupFile(targetPath, project); // ← project hinzufügen
File.Delete(targetPath);
}
// Stelle 2: Overwrite-Branch (Zeile ~69)
if (File.Exists(targetPath))
BackupFile(targetPath, project); // ← project hinzufügen
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!) bleibt unverändert (Zeile ~66 im aktuellen Code) – dies ist die Zielverzeichnis-Erstellung und ist von der Backup-Verzeichnis-Erstellung in BackupFile() unabhängig.
Verhalten bei ChangeType.Deleted:
BackupEnabled = true: Zieldatei wird gesichert (.bak), dann gelöscht. (Konsistent mit bisherigem Verhalten.)BackupEnabled = false: Zieldatei wird unwiederbringlich gelöscht. Dies ist gewünschtes Verhalten – der User hat Backups explizit deaktiviert.
Neue CleanupOldBackups()-Methode:
CleanupOldBackups(backupDir, baseName, ext, max):
1. Suche alle Dateien: {baseName}_*{ext}.bak im backupDir
2. Sortiere alphabetisch aufsteigend nach Dateiname
(Format yyyyMMdd_HHmmss ist lexikografisch geordnet → korrekte zeitliche Reihenfolge)
NICHT nach Filesystem-Erstelldatum sortieren (File.Move behält das Original-Erstelldatum)
3. Lösche alle Einträge über dem Limit (älteste zuerst = vorne in der sortierten Liste)
4. Fehler beim Löschen: LogWarning, kein Abbruch
Rename-Szenario (ChangeType.Renamed):
Backups, die unter dem alten Dateinamen angelegt wurden, werden von CleanupOldBackups nicht erfasst (anderer baseName). Diese orphaned Backups akkumulieren sich unter dem alten Namen. Dieses Verhalten ist explizit akzeptiert und liegt außerhalb des Scopes dieser Änderung.
4. Service – API-Modelle
CreateProjectRequest und UpdateProjectRequest erhalten:
bool BackupEnabled = true
string? BackupPath = null
int MaxBackupsPerFile = 0
Validierungsregel für BackupPath in ProjectsApi:
BackupPath wird bei POST/PUT nicht auf Existenz geprüft. Fehlende Verzeichnisse werden beim ersten Sync per Directory.CreateDirectory() automatisch erzeugt. (Abweichend von EngineeringPath/SimulationPath, die auf Existenz geprüft werden.)
ProjectsApi mappt die neuen Felder beim Erstellen/Aktualisieren der ProjectConfig.
5. Setup-Wizard
Neue Datei: EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml + .cs
WizardState – neue Properties:
[ObservableProperty] private bool _backupEnabled = true;
[ObservableProperty] private bool _backupUseCustomPath = false;
[ObservableProperty] private string _backupCustomPath = string.Empty;
[ObservableProperty] private int _maxBackupsPerFile = 0;
WizardViewModel – Wizard-Schritte steigen von 7 auf 8:
Index 0: Willkommen
Index 1: Installation
Index 2: Erstes Projekt
Index 3: Backup ← NEU WizardStep("Backup", "\uE72E")
Index 4: Optionen
Index 5: Zusammenfassung
Index 6: Installation
Index 7: Fertig
WizardViewModel.Steps erhält WizardStep("Backup", "\uE72E") an Position 3. _pageFactories erhält () => new BackupOptionsPage(this) an Position 3. IsLastStep = Steps.Count - 2 bleibt unverändert (ergibt mit 8 Schritten korrekt Index 6 = „Zusammenfassung").
BackupOptionsPage-Inhalt:
OptionCard/CheckBox: „Backups vor dem Überschreiben aktivieren" (Standard: an)- Wenn aktiviert (Visibility via
BoolToVisibilityConverter):- Radio: „Gleicher Ordner wie Simulationsdatei" (Standard)
- Radio: „Eigener Backup-Ordner" → TextBox + Browse-Button (
FolderBrowserDialog) - TextBox + Label: „Maximal ___ Backups pro Datei (0 = unbegrenzt)"
InstallerService.WriteFirstRunConfig() schreibt Backup-Felder als neuen Backup-Abschnitt in firstrun-config.json:
{
"FirstRun": { ... },
"Backup": {
"BackupEnabled": true,
"BackupPath": null,
"MaxBackupsPerFile": 0
}
}
Program.cs – ProcessFirstRunConfigAsync()-Funktion (NICHT Worker.cs – die Verarbeitung der firstrun-config.json liegt als lokale Funktion in Program.cs, Zeile ~42):
- Liest den neuen
Backup-Abschnitt viaJsonDocument(mitTryGetPropertyfür Rückwärtskompatibilität, fallsBackup-Abschnitt fehlt → Standardwerte) - Setzt
BackupEnabled,BackupPath,MaxBackupsPerFilebeim Erstellen derProjectConfig
SummaryPage zeigt:
SummaryBoolRow: „Backups aktiviert"SummaryRow(conditional, wennBackupUseCustomPath): „Backup-Ordner: {Pfad}"SummaryRow(conditional, wennMaxBackupsPerFile > 0): „Max. Backups pro Datei: {N}"
6. TrayApp – ProjectManagementWindow
ProjectManagementViewModel – neue Properties:
[ObservableProperty] private bool _backupEnabled;
[ObservableProperty] private bool _backupUseCustomPath;
[ObservableProperty] private string _backupCustomPath = string.Empty;
[ObservableProperty] private int _maxBackupsPerFile;
XAML – neue BACKUP-Sektion im Bearbeitungs-Formular (analog zu bestehenden Sektionen):
BACKUP
├── CheckBox: „Backups vor dem Überschreiben erstellen"
├── (sichtbar wenn aktiviert, via BoolToVisibilityConverter):
│ ├── RadioButton: „Gleicher Ordner wie Simulationsdatei"
│ ├── RadioButton: „Eigener Ordner:" + TextBox + [Durchsuchen]
│ └── TextBox: „Maximal ___ Backups pro Datei (0 = unbegrenzt)"
TrayApp ApiClient – DTOs:
CreateProjectDto und UpdateProjectDto in ApiClient.cs erhalten:
bool BackupEnabled = true
string? BackupPath = null
int MaxBackupsPerFile = 0
ApiClient.CreateProjectAsync() und UpdateProjectAsync() senden die Backup-Felder mit.
Datenfluss
[Wizard BackupOptionsPage]
↓ WizardState
[InstallerService] → firstrun-config.json (Backup-Abschnitt)
↓
[Program.cs ProcessFirstRunConfigAsync()] → ProjectConfig (mit Backup-Feldern) in SQLite
[TrayApp ProjectManagementWindow]
↓ PUT /api/projects/{id} (mit Backup-Feldern in UpdateProjectDto)
[ProjectsApi] → ProjectConfig in SQLite
↓
[SyncManager.ProcessChangeAsync]
├─ Directory.CreateDirectory(targetDir) ← Zielverzeichnis
└─ BackupFile(targetPath, project)
├─ if !BackupEnabled → return
├─ backupDir = BackupPath ?? targetDir
├─ Directory.CreateDirectory(backupDir) ← Backup-Verzeichnis
├─ File.Move(targetPath, backupPath)
└─ CleanupOldBackups() wenn MaxBackupsPerFile > 0
Fehlerbehandlung
| Szenario | Verhalten |
|---|---|
| Backup-Ordner existiert nicht | Directory.CreateDirectory() in BackupFile() erzeugt ihn beim ersten Sync |
| Backup-Ordner nicht beschreibbar | Exception → von ProcessChangeAsync gefangen → failed-Zähler |
| Cleanup schlägt fehl | logger.LogWarning, kein Sync-Abbruch |
BackupEnabled=false + Deleted |
Zieldatei wird unwiederbringlich gelöscht (gewünscht) |
BackupEnabled=true + Deleted |
Backup wird erstellt, dann Zieldatei gelöscht (bisheriges Verhalten) |
BackupPath bei POST/PUT nicht existent |
Kein API-Fehler; Verzeichnis wird beim ersten Sync erstellt |
| Rename-Szenario: alte Backups | Akkumulieren unter altem Dateinamen; kein automatisches Cleanup (out of scope) |
Nicht im Scope
- Backups wiederherstellen (Restore-UI)
- Backup-Komprimierung (.zip)
- Backup-Statistiken / Speicherplatz-Anzeige
- Globale (projektübergreifende) Backup-Einstellungen
- Cleanup von Backups unter altem Dateinamen nach Rename