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>
264 lines
11 KiB
Markdown
264 lines
11 KiB
Markdown
# 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`
|
||
|
||
```csharp
|
||
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()` in `OnModelCreating` für bestehende Rows:
|
||
- `BackupEnabled`: `true`
|
||
- `BackupPath`: `null`
|
||
- `MaxBackupsPerFile`: `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)`:**
|
||
1. `if (!project.BackupEnabled) return;` — kein Backup, sofort zurück
|
||
2. `backupDir = project.BackupPath ?? Path.GetDirectoryName(targetPath)`
|
||
3. `Directory.CreateDirectory(backupDir)` — **nur** Backup-Verzeichnis (unabhängig vom Ziel-Verzeichnis)
|
||
4. Backup-Dateiname: `{name}_{DateTime.Now:yyyyMMdd_HHmmss}{ext}.bak`
|
||
5. Backup-Datei verschieben (laufwerk-übergreifend sicher):
|
||
- Versuch: `File.Move(targetPath, backupPath)` (schnell, gleiche Volume)
|
||
- Falls `IOException` (z.B. andere Volume, Netzlaufwerk): `File.Copy` + `File.Delete` als Fallback
|
||
6. `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:
|
||
```csharp
|
||
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:**
|
||
```csharp
|
||
[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`:
|
||
```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 via `JsonDocument` (mit `TryGetProperty` für Rückwärtskompatibilität, falls `Backup`-Abschnitt fehlt → Standardwerte)
|
||
- Setzt `BackupEnabled`, `BackupPath`, `MaxBackupsPerFile` beim Erstellen der `ProjectConfig`
|
||
|
||
**`SummaryPage`** zeigt:
|
||
- `SummaryBoolRow`: „Backups aktiviert"
|
||
- `SummaryRow` (conditional, wenn `BackupUseCustomPath`): „Backup-Ordner: {Pfad}"
|
||
- `SummaryRow` (conditional, wenn `MaxBackupsPerFile > 0`): „Max. Backups pro Datei: {N}"
|
||
|
||
### 6. TrayApp – `ProjectManagementWindow`
|
||
|
||
**`ProjectManagementViewModel` – neue Properties:**
|
||
```csharp
|
||
[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:
|
||
```csharp
|
||
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
|