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

11 KiB
Raw Permalink Blame History

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() 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:

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 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:

[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