Files
EngineeringSync/docs/superpowers/specs/2026-03-26-backup-settings-design.md
EngineeringSync 04ae8a0aae Initial commit: EngineeringSync v1.0.0
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>
2026-03-26 21:52:26 +01:00

264 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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