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>
This commit is contained in:
EngineeringSync
2026-03-26 21:52:26 +01:00
commit 04ae8a0aae
98 changed files with 8172 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
using EngineeringSync.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace EngineeringSync.Infrastructure;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder.IsConfigured) return;
// WAL-Modus für bessere Nebenläufigkeit zwischen Watcher-Writer und API-Reader
optionsBuilder.UseSqlite(o => o.CommandTimeout(30));
}
public DbSet<ProjectConfig> Projects => Set<ProjectConfig>();
public DbSet<FileRevision> FileRevisions => Set<FileRevision>();
public DbSet<PendingChange> PendingChanges => Set<PendingChange>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ProjectConfig>(e =>
{
e.HasKey(p => p.Id);
e.Property(p => p.Name).IsRequired().HasMaxLength(200);
e.Property(p => p.EngineeringPath).IsRequired();
e.Property(p => p.SimulationPath).IsRequired();
e.Property(p => p.BackupEnabled).HasDefaultValue(true);
e.Property(p => p.BackupPath).HasDefaultValue(null);
e.Property(p => p.MaxBackupsPerFile).HasDefaultValue(0);
});
modelBuilder.Entity<FileRevision>(e =>
{
e.HasKey(r => r.Id);
e.HasIndex(r => new { r.ProjectId, r.RelativePath }).IsUnique();
e.HasOne(r => r.Project)
.WithMany(p => p.FileRevisions)
.HasForeignKey(r => r.ProjectId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<PendingChange>(e =>
{
e.HasKey(c => c.Id);
e.HasOne(c => c.Project)
.WithMany(p => p.PendingChanges)
.HasForeignKey(c => c.ProjectId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\EngineeringSync.Domain\EngineeringSync.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,154 @@
// <auto-generated />
using System;
using EngineeringSync.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EngineeringSync.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260325090857_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("EngineeringSync.Domain.Entities.FileRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("FileHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Size")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId", "RelativePath")
.IsUnique();
b.ToTable("FileRevisions");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.PendingChange", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ChangeType")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("OldRelativePath")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("PendingChanges");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.ProjectConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EngineeringPath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileExtensions")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SimulationPath")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.FileRevision", b =>
{
b.HasOne("EngineeringSync.Domain.Entities.ProjectConfig", "Project")
.WithMany("FileRevisions")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.PendingChange", b =>
{
b.HasOne("EngineeringSync.Domain.Entities.ProjectConfig", "Project")
.WithMany("PendingChanges")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.ProjectConfig", b =>
{
b.Navigation("FileRevisions");
b.Navigation("PendingChanges");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,102 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EngineeringSync.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Projects",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
EngineeringPath = table.Column<string>(type: "TEXT", nullable: false),
SimulationPath = table.Column<string>(type: "TEXT", nullable: false),
FileExtensions = table.Column<string>(type: "TEXT", nullable: false),
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Projects", x => x.Id);
});
migrationBuilder.CreateTable(
name: "FileRevisions",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
RelativePath = table.Column<string>(type: "TEXT", nullable: false),
FileHash = table.Column<string>(type: "TEXT", nullable: false),
Size = table.Column<long>(type: "INTEGER", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FileRevisions", x => x.Id);
table.ForeignKey(
name: "FK_FileRevisions_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PendingChanges",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
RelativePath = table.Column<string>(type: "TEXT", nullable: false),
ChangeType = table.Column<int>(type: "INTEGER", nullable: false),
OldRelativePath = table.Column<string>(type: "TEXT", nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
SyncedAt = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PendingChanges", x => x.Id);
table.ForeignKey(
name: "FK_PendingChanges_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_FileRevisions_ProjectId_RelativePath",
table: "FileRevisions",
columns: new[] { "ProjectId", "RelativePath" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PendingChanges_ProjectId",
table: "PendingChanges",
column: "ProjectId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FileRevisions");
migrationBuilder.DropTable(
name: "PendingChanges");
migrationBuilder.DropTable(
name: "Projects");
}
}
}

View File

@@ -0,0 +1,167 @@
// <auto-generated />
using System;
using EngineeringSync.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EngineeringSync.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260326161840_AddBackupSettingsToProjectConfig")]
partial class AddBackupSettingsToProjectConfig
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("EngineeringSync.Domain.Entities.FileRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("FileHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Size")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId", "RelativePath")
.IsUnique();
b.ToTable("FileRevisions");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.PendingChange", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ChangeType")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("OldRelativePath")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("PendingChanges");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.ProjectConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("BackupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("BackupPath")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EngineeringPath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileExtensions")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxBackupsPerFile")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SimulationPath")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.FileRevision", b =>
{
b.HasOne("EngineeringSync.Domain.Entities.ProjectConfig", "Project")
.WithMany("FileRevisions")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.PendingChange", b =>
{
b.HasOne("EngineeringSync.Domain.Entities.ProjectConfig", "Project")
.WithMany("PendingChanges")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.ProjectConfig", b =>
{
b.Navigation("FileRevisions");
b.Navigation("PendingChanges");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EngineeringSync.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBackupSettingsToProjectConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BackupEnabled",
table: "Projects",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<string>(
name: "BackupPath",
table: "Projects",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MaxBackupsPerFile",
table: "Projects",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BackupEnabled",
table: "Projects");
migrationBuilder.DropColumn(
name: "BackupPath",
table: "Projects");
migrationBuilder.DropColumn(
name: "MaxBackupsPerFile",
table: "Projects");
}
}
}

View File

@@ -0,0 +1,164 @@
// <auto-generated />
using System;
using EngineeringSync.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EngineeringSync.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("EngineeringSync.Domain.Entities.FileRevision", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("FileHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Size")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId", "RelativePath")
.IsUnique();
b.ToTable("FileRevisions");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.PendingChange", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ChangeType")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("OldRelativePath")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime?>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("PendingChanges");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.ProjectConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("BackupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("BackupPath")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EngineeringPath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileExtensions")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int>("MaxBackupsPerFile")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SimulationPath")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.FileRevision", b =>
{
b.HasOne("EngineeringSync.Domain.Entities.ProjectConfig", "Project")
.WithMany("FileRevisions")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.PendingChange", b =>
{
b.HasOne("EngineeringSync.Domain.Entities.ProjectConfig", "Project")
.WithMany("PendingChanges")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("EngineeringSync.Domain.Entities.ProjectConfig", b =>
{
b.Navigation("FileRevisions");
b.Navigation("PendingChanges");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace EngineeringSync.Infrastructure;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string dbPath)
{
// WAL=Write-Ahead Logging: ermöglicht gleichzeitiges Lesen (API) und Schreiben (Watcher)
var connectionString = $"Data Source={dbPath};Mode=ReadWriteCreate;Cache=Shared";
services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite(connectionString));
return services;
}
}