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

22
.gitattributes vendored Normal file
View File

@@ -0,0 +1,22 @@
# Zeilenenden normalisieren
* text=auto
# .NET-spezifisch
*.cs text eol=crlf
*.xaml text eol=crlf
*.csproj text eol=crlf
*.slnx text eol=crlf
*.sln text eol=crlf
*.props text eol=crlf
*.targets text eol=crlf
*.config text eol=crlf
*.json text eol=crlf
*.md text eol=crlf
*.ps1 text eol=crlf
*.iss text eol=crlf
# Binärdateien
*.png binary
*.ico binary
*.exe binary
*.dll binary

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Build-Ausgaben
bin/
obj/
dist/
# Heruntergeladene Redistributables (werden beim Build automatisch geladen)
installer/redist/
# User-spezifische IDE-Dateien
.vs/
*.user
*.suo
# dotnet tools
.dotnet/
# Logs
*.log
firebase-debug.log
# Serena / Claude intern
.serena/
.claude/settings.local.json

155
AGENTS.md Normal file
View File

@@ -0,0 +1,155 @@
# AGENTS.md
## Build, Test & Run Commands
```bash
# Build entire solution
dotnet build EngineeringSync.slnx
# Run the background service (dev mode)
dotnet run --project EngineeringSync.Service
# Run the WPF tray app
dotnet run --project EngineeringSync.TrayApp
# Run all tests
dotnet test
# Run a single test project
dotnet test EngineeringSync.Tests --filter "FullyQualifiedName~WatcherServiceTests"
# Run tests with verbose output
dotnet test --verbosity normal
# Build Release
dotnet build EngineeringSync.slnx -c Release
# Install as Windows Service (production)
sc create EngineeringSync binPath="<path>\EngineeringSync.Service.exe"
# Build installer (requires Inno Setup 6)
.\installer\build-installer.ps1
```
## Code Style Guidelines
### General Principles
- Use C# 12+ features (file-scoped namespaces, collection expressions, primary constructors)
- Use `[ObservableProperty]` and `[RelayCommand]` attributes from CommunityToolkit.Mvvm
- Prefer source generators over boilerplate code
### Imports
- Sort by: System namespaces first, then Microsoft, then third-party, then project-specific
- Use file-scoped namespaces: `namespace EngineeringSync.Domain.Entities;`
- Global using statements in each project for common types
### Types & Collections
- Use collection expressions `[]` instead of `new List<T>()` where possible
- Use `IReadOnlyList<T>` for parameters that should not be modified
- Use `Guid` for all ID types
- Use `DateTime.UtcNow` for timestamps, convert to local time in UI layer only
### Naming Conventions
- **Classes/Interfaces:** PascalCase (e.g., `WatcherService`, `IApiClient`)
- **Methods:** PascalCase (e.g., `StartWatchingAsync`)
- **Private fields:** camelCase (e.g., `_watchers`, `_channel`)
- **Properties:** PascalCase (e.g., `IsActive`, `EngineeringPath`)
- **Parameters:** camelCase (e.g., `project`, `stoppingToken`)
- **Constants:** PascalCase (e.g., `DefaultTimeout`)
### Records vs Classes
- Use `record` for DTOs and API models
- Use `class` for entities with mutable properties
### Error Handling
- Use `try/catch` with specific exception types
- Log exceptions with context using `ILogger` (use structured logging: `logger.LogError(ex, "Message {Param}", param)`)
- In ViewModels, catch exceptions and set `StatusMessage` for user feedback
### Async/Await
- Always use `Async` suffix for async methods
- Pass `CancellationToken` to all cancellable operations
- Use `await using` for `IDisposable` resources that implement `IAsyncDisposable`
### Entity Framework Core
- Use `IDbContextFactory<AppDbContext>` for scoped DbContext in background services
- Use `await using var db = await dbFactory.CreateDbContextAsync(ct);`
- Configure SQLite with WAL mode for better concurrency
### WPF / MVVM
- ViewModels inherit from `ObservableObject` (CommunityToolkit.Mvvm)
- Use `[ObservableProperty]` for properties that need change notification
- Use `[RelayCommand]` for commands
- Use `partial class` for generated properties
### Dependency Injection
- Constructor injection with primary constructors:
```csharp
public sealed class WatcherService(
IDbContextFactory<AppDbContext> dbFactory,
IHubContext<NotificationHub> hub,
ILogger<WatcherService> logger) : BackgroundService
```
### File Organization
```
EngineeringSync.Domain/ - Entities, Enums, Interfaces
EngineeringSync.Infrastructure/ - EF Core, DbContext, Migrations
EngineeringSync.Service/ - API, Hubs, Background Services
EngineeringSync.TrayApp/ - WPF Views, ViewModels, Services
EngineeringSync.Setup/ - Installer wizard
```
### Key Patterns
**FileSystemWatcher Debouncing:** Events flow into a `Channel<FileEvent>`, consumer groups by `(ProjectId, RelativePath)` within 2000ms window, SHA-256 confirms actual changes.
**SignalR Notifications:** `NotificationHub` at `/notifications` broadcasts `ReceiveChangeNotification(projectId, projectName, count)` to all clients.
**Backup before sync:** Use timestamped naming `{filename}_{yyyyMMdd_HHmmss}.bak`.
### Service API Endpoints
- `GET/POST/PUT/DELETE /api/projects` - Project CRUD
- `GET /api/changes/{projectId}` - Get pending changes
- `GET /api/changes/{projectId}/history` - Get synced/ignored changes
- `POST /api/sync` - Sync selected changes
- `POST /api/ignore` - Ignore selected changes
### Database
- SQLite with WAL mode
- Entities: `ProjectConfig`, `FileRevision`, `PendingChange`
- `ProjectConfig.FileExtensions` is comma-separated (e.g., ".jt,.cojt,.xml")
## Solution Structure
```
EngineeringSync.slnx
├── EngineeringSync.Domain/ (Class Library, net10.0) - Entities, Enums, Interfaces
├── EngineeringSync.Infrastructure/ (Class Library, net10.0) - EF Core, DbContext
├── EngineeringSync.Service/ (Worker Service, net10.0) - Kestrel on :5050, SignalR hub
├── EngineeringSync.TrayApp/ (WPF App, net10.0-windows) - System tray + UI windows
├── EngineeringSync.Setup/ (WPF App, net10.0-windows) - Setup wizard + Installer
└── installer/
├── setup.iss - Inno Setup Script
└── build-installer.ps1 - Build + Publish + ISCC Pipeline
```
## Tech Stack
- **Framework:** .NET 10
- **Service:** Worker Service (Windows Service) + ASP.NET Core Minimal API + SignalR
- **Client:** WPF (.NET 10-Windows) with `H.NotifyIcon.Wpf` + `CommunityToolkit.Mvvm`
- **Database:** SQLite via EF Core 10 (WAL mode)
- **File Watching:** `System.IO.FileSystemWatcher` + `System.Threading.Channels` (debouncing)
## Key Architecture
- **ProjectConfig in DB:** Managed via TrayApp UI, stored in SQLite. User adds/edits/deletes projects with folder browser dialogs. CRUD operations go through Service API, which dynamically starts/stops watchers.
- **FileSystemWatcher Debouncing:** Events (Created, Changed, Renamed, Deleted) pushed into `Channel<FileEvent>`. Consumer groups events by `(ProjectId, RelativePath)` within 2000ms sliding window. SHA-256 hashing against `FileRevision` confirms actual changes before writing a `PendingChange`.
- **SignalR Hub:** `NotificationHub` at `/notifications` broadcasts `ReceiveChangeNotification(projectId, projectName, count)` and `ProjectConfigChanged()`.
## Notes
- Domain project has no dependencies (pure entities/enums)
- Infrastructure depends on Domain
- Service depends on Domain + Infrastructure
- TrayApp depends on Domain + Service (via HTTP client)

90
CLAUDE.md Normal file
View File

@@ -0,0 +1,90 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**EngineeringSync** A .NET 10 middleware tool that bridges mechanical engineering (CAD) and simulation (Process Simulate). A Windows Service watches an engineering folder for file changes, records them in SQLite, and notifies a WPF System Tray App via SignalR. The simulation engineer reviews changes and triggers a controlled sync to avoid data corruption.
## Tech Stack
- **Framework:** .NET 10
- **Service:** Worker Service (Windows Service) + ASP.NET Core Minimal API + SignalR
- **Client:** WPF (.NET 10-Windows) with `H.NotifyIcon.Wpf` + `CommunityToolkit.Mvvm`
- **Database:** SQLite via EF Core 10 (WAL mode)
- **File Watching:** `System.IO.FileSystemWatcher` + `System.Threading.Channels` (debouncing)
## Solution Structure
```
EngineeringSync.slnx
├── EngineeringSync.Domain (Class Library, net10.0) Entities, Enums, Interfaces
├── EngineeringSync.Infrastructure (Class Library, net10.0) EF Core, AppDbContext
├── EngineeringSync.Service (Worker Service, net10.0) Kestrel on :5050, SignalR hub
├── EngineeringSync.TrayApp (WPF App, net10.0-windows) System tray + UI windows
└── EngineeringSync.Setup (WPF App, net10.0-windows) Setup-Wizard + Installer
installer/
├── setup.iss Inno Setup Script
└── build-installer.ps1 Build + Publish + ISCC Pipeline
```
**Dependency direction (strict one-way):**
`TrayApp``Domain` | `Service``Domain` + `Infrastructure` | `Infrastructure``Domain`
## Build & Run
```bash
# Build entire solution
dotnet build EngineeringSync.slnx
# Installer bauen (benötigt Inno Setup 6 installiert)
.\installer\build-installer.ps1
# Run the background service (dev mode)
dotnet run --project EngineeringSync.Service
# Run the WPF tray app
dotnet run --project EngineeringSync.TrayApp
# Run all tests
dotnet test
# Run a single test project
dotnet test EngineeringSync.Tests --filter "FullyQualifiedName~WatcherServiceTests"
# Install as Windows Service (production)
sc create EngineeringSync binPath="<path>\EngineeringSync.Service.exe"
```
## Key Architecture Decisions
### ProjectConfig in DB (not appsettings.json)
`ProjectConfig` is managed via the TrayApp UI and stored in SQLite. The user can add/edit/delete projects with folder browser dialogs. CRUD operations go through the Service API, which dynamically starts/stops watchers.
### FileSystemWatcher Debouncing
Events (Created, Changed, Renamed, Deleted) are pushed into a `Channel<FileEvent>`. A consumer groups events by `(ProjectId, RelativePath)` within a 2000ms sliding window. SHA-256 hashing against `FileRevision` confirms actual changes before writing a `PendingChange`.
### Service API (Kestrel on localhost:5050)
- **Project CRUD:** `GET/POST/PUT/DELETE /api/projects`
- **Changes:** `GET /api/changes/{projectId}`, `GET /api/changes/{projectId}/history`
- **Actions:** `POST /api/sync`, `POST /api/ignore`
Backup before overwrite uses timestamped naming: `{filename}_{yyyyMMdd_HHmmss}.bak`
### SignalR Hub (`NotificationHub` at `/notifications`)
- `ReceiveChangeNotification(projectId, projectName, count)` New pending changes
- `ProjectConfigChanged()` Project CRUD happened
### TrayApp Windows
- **ProjectManagementWindow** CRUD for projects with `FolderBrowserDialog` for path selection
- **PendingChangesWindow** DataGrid with project selector, sync/ignore actions, auto-refresh via SignalR
All ViewModels use `CommunityToolkit.Mvvm` source generators (`[ObservableProperty]`, `[RelayCommand]`).
## Domain Entities
| Entity | Key Fields |
|---|---|
| `ProjectConfig` | `Id` (Guid), `Name`, `EngineeringPath`, `SimulationPath`, `FileExtensions`, `IsActive`, `CreatedAt` |
| `FileRevision` | `Id` (Guid), `ProjectId` (FK), `RelativePath`, `FileHash`, `Size`, `LastModified` |
| `PendingChange` | `Id` (Guid), `ProjectId` (FK), `RelativePath`, `ChangeType` (Created/Modified/Renamed/Deleted), `OldRelativePath?`, `Status` (Pending/Synced/Ignored), `CreatedAt`, `SyncedAt?` |

View File

@@ -0,0 +1,7 @@
namespace EngineeringSync.Domain.Constants;
public static class HubMethodNames
{
public const string ReceiveChangeNotification = "ReceiveChangeNotification";
public const string ProjectConfigChanged = "ProjectConfigChanged";
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,12 @@
namespace EngineeringSync.Domain.Entities;
public class FileRevision
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public ProjectConfig Project { get; set; } = null!;
public string RelativePath { get; set; } = string.Empty;
public string FileHash { get; set; } = string.Empty;
public long Size { get; set; }
public DateTime LastModified { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace EngineeringSync.Domain.Entities;
public class PendingChange
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public ProjectConfig Project { get; set; } = null!;
public string RelativePath { get; set; } = string.Empty;
public ChangeType ChangeType { get; set; }
public string? OldRelativePath { get; set; }
public ChangeStatus Status { get; set; } = ChangeStatus.Pending;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? SyncedAt { get; set; }
}

View File

@@ -0,0 +1,28 @@
namespace EngineeringSync.Domain.Entities;
public class ProjectConfig
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public string EngineeringPath { get; set; } = string.Empty;
public string SimulationPath { get; set; } = string.Empty;
/// <summary>Komma-separiert, z.B. ".jt,.cojt,.xml"</summary>
public string FileExtensions { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public bool BackupEnabled { get; set; } = true;
public string? BackupPath { get; set; } = null;
public int MaxBackupsPerFile { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<FileRevision> FileRevisions { get; set; } = [];
public ICollection<PendingChange> PendingChanges { get; set; } = [];
public IEnumerable<string> GetExtensions()
{
// Wenn leer oder "*" → alle Dateien beobachten
if (string.IsNullOrWhiteSpace(FileExtensions) || FileExtensions.Trim() == "*")
return ["*"];
return FileExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
}

View File

@@ -0,0 +1,8 @@
namespace EngineeringSync.Domain.Entities;
public enum ChangeStatus
{
Pending,
Synced,
Ignored
}

View File

@@ -0,0 +1,9 @@
namespace EngineeringSync.Domain.Entities;
public enum ChangeType
{
Created,
Modified,
Renamed,
Deleted
}

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;
}
}

View File

@@ -0,0 +1,52 @@
using EngineeringSync.Domain.Entities;
using EngineeringSync.Infrastructure;
using EngineeringSync.Service.Models;
using EngineeringSync.Service.Services;
using Microsoft.EntityFrameworkCore;
namespace EngineeringSync.Service.Api;
public static class ChangesApi
{
public static IEndpointRouteBuilder MapChangesApi(this IEndpointRouteBuilder app)
{
app.MapGet("/api/changes/{projectId:guid}", async (Guid projectId, IDbContextFactory<AppDbContext> dbFactory,
int page = 0, int pageSize = 100) =>
{
await using var db = await dbFactory.CreateDbContextAsync();
var changes = await db.PendingChanges
.Where(c => c.ProjectId == projectId && c.Status == ChangeStatus.Pending)
.OrderByDescending(c => c.CreatedAt)
.Skip(page * pageSize)
.Take(pageSize)
.ToListAsync();
return Results.Ok(changes);
});
app.MapGet("/api/changes/{projectId:guid}/history", async (Guid projectId,
IDbContextFactory<AppDbContext> dbFactory) =>
{
await using var db = await dbFactory.CreateDbContextAsync();
var changes = await db.PendingChanges
.Where(c => c.ProjectId == projectId && c.Status != ChangeStatus.Pending)
.OrderByDescending(c => c.SyncedAt ?? c.CreatedAt)
.Take(100)
.ToListAsync();
return Results.Ok(changes);
});
app.MapPost("/api/sync", async (SyncRequest req, SyncManager syncManager) =>
{
var result = await syncManager.SyncAsync(req.ChangeIds);
return Results.Ok(result);
});
app.MapPost("/api/ignore", async (IgnoreRequest req, SyncManager syncManager) =>
{
await syncManager.IgnoreAsync(req.ChangeIds);
return Results.NoContent();
});
return app;
}
}

View File

@@ -0,0 +1,173 @@
using EngineeringSync.Domain.Constants;
using EngineeringSync.Domain.Entities;
using EngineeringSync.Infrastructure;
using EngineeringSync.Service.Hubs;
using EngineeringSync.Service.Models;
using EngineeringSync.Service.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace EngineeringSync.Service.Api;
public static class ProjectsApi
{
public static IEndpointRouteBuilder MapProjectsApi(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/projects");
group.MapGet("/", async (IDbContextFactory<AppDbContext> dbFactory) =>
{
await using var db = await dbFactory.CreateDbContextAsync();
var projects = await db.Projects.OrderBy(p => p.Name).ToListAsync();
return Results.Ok(projects);
});
group.MapGet("/{id:guid}", async (Guid id, IDbContextFactory<AppDbContext> dbFactory) =>
{
await using var db = await dbFactory.CreateDbContextAsync();
var project = await db.Projects.FindAsync(id);
return project is null ? Results.NotFound() : Results.Ok(project);
});
group.MapPost("/", async (CreateProjectRequest req, IDbContextFactory<AppDbContext> dbFactory,
WatcherService watcher, IHubContext<NotificationHub> hubContext, ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("ProjectsApi");
if (!Directory.Exists(req.EngineeringPath))
return Results.BadRequest($"Engineering-Pfad existiert nicht: {req.EngineeringPath}");
if (!Directory.Exists(req.SimulationPath))
return Results.BadRequest($"Simulations-Pfad existiert nicht: {req.SimulationPath}");
await using var db = await dbFactory.CreateDbContextAsync();
var project = new ProjectConfig
{
Name = req.Name,
EngineeringPath = req.EngineeringPath,
SimulationPath = req.SimulationPath,
FileExtensions = req.FileExtensions,
IsActive = req.IsActive,
BackupEnabled = req.BackupEnabled,
BackupPath = req.BackupPath,
MaxBackupsPerFile = req.MaxBackupsPerFile
};
db.Projects.Add(project);
await db.SaveChangesAsync();
await watcher.StartWatchingAsync(project);
_ = Task.Run(async () =>
{
try { await watcher.ScanExistingFilesAsync(project); }
catch (Exception ex) { logger.LogError(ex, "Fehler beim initialen Scan für Projekt {Id}", project.Id); }
});
await hubContext.Clients.All.SendAsync(HubMethodNames.ProjectConfigChanged, CancellationToken.None);
return Results.Created($"/api/projects/{project.Id}", project);
});
group.MapPut("/{id:guid}", async (Guid id, UpdateProjectRequest req,
IDbContextFactory<AppDbContext> dbFactory, WatcherService watcher, IHubContext<NotificationHub> hubContext,
ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("ProjectsApi");
await using var db = await dbFactory.CreateDbContextAsync();
var project = await db.Projects.FindAsync(id);
if (project is null) return Results.NotFound();
if (!Directory.Exists(req.EngineeringPath))
return Results.BadRequest($"Engineering-Pfad existiert nicht: {req.EngineeringPath}");
if (!Directory.Exists(req.SimulationPath))
return Results.BadRequest($"Simulations-Pfad existiert nicht: {req.SimulationPath}");
project.Name = req.Name;
project.EngineeringPath = req.EngineeringPath;
project.SimulationPath = req.SimulationPath;
project.FileExtensions = req.FileExtensions;
project.IsActive = req.IsActive;
project.BackupEnabled = req.BackupEnabled;
project.BackupPath = req.BackupPath;
project.MaxBackupsPerFile = req.MaxBackupsPerFile;
await db.SaveChangesAsync();
// Watcher neu starten (stoppt automatisch den alten)
await watcher.StartWatchingAsync(project);
_ = Task.Run(async () =>
{
try { await watcher.ScanExistingFilesAsync(project); }
catch (Exception ex) { logger.LogError(ex, "Fehler beim initialen Scan für Projekt {Id}", project.Id); }
});
await hubContext.Clients.All.SendAsync(HubMethodNames.ProjectConfigChanged, CancellationToken.None);
return Results.Ok(project);
});
group.MapDelete("/{id:guid}", async (Guid id, IDbContextFactory<AppDbContext> dbFactory,
WatcherService watcher, IHubContext<NotificationHub> hubContext) =>
{
await using var db = await dbFactory.CreateDbContextAsync();
var project = await db.Projects.FindAsync(id);
if (project is null) return Results.NotFound();
await watcher.StopWatchingAsync(id);
db.Projects.Remove(project);
await db.SaveChangesAsync();
await hubContext.Clients.All.SendAsync(HubMethodNames.ProjectConfigChanged, CancellationToken.None);
return Results.NoContent();
});
group.MapGet("/scan", async (string engineeringPath, string simulationPath, string fileExtensions) =>
{
if (!Directory.Exists(engineeringPath))
return Results.BadRequest($"Engineering-Pfad existiert nicht: {engineeringPath}");
if (!Directory.Exists(simulationPath))
return Results.BadRequest($"Simulations-Pfad existiert nicht: {simulationPath}");
var extensions = string.IsNullOrWhiteSpace(fileExtensions) || fileExtensions == "*"
? new HashSet<string>()
: fileExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var scanAll = extensions.Count == 0;
var engFiles = scanAll
? Directory.EnumerateFiles(engineeringPath, "*", SearchOption.AllDirectories).ToList()
: Directory.EnumerateFiles(engineeringPath, "*", SearchOption.AllDirectories)
.Where(f => extensions.Contains(Path.GetExtension(f)))
.ToList();
var results = new List<ScanResultEntry>();
foreach (var engFile in engFiles)
{
var relativePath = Path.GetRelativePath(engineeringPath, engFile);
var simFile = Path.Combine(simulationPath, relativePath);
var engInfo = new FileInfo(engFile);
var engHash = await FileHasher.ComputeAsync(engFile);
var existsInSim = File.Exists(simFile);
var needsSync = !existsInSim;
if (existsInSim)
{
var simHash = await FileHasher.ComputeAsync(simFile);
needsSync = engHash != simHash;
}
if (needsSync)
{
results.Add(new ScanResultEntry(
relativePath,
existsInSim ? "Modified" : "Created",
engInfo.Length,
engInfo.LastWriteTimeUtc
));
}
}
return Results.Ok(results);
});
return app;
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-EngineeringSync.Service-6b7e2096-9433-4768-9912-b8f8fa4ad393</UserSecretsId>
</PropertyGroup>
<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.Extensions.Hosting.WindowsServices" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EngineeringSync.Domain\EngineeringSync.Domain.csproj" />
<ProjectReference Include="..\EngineeringSync.Infrastructure\EngineeringSync.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.SignalR;
namespace EngineeringSync.Service.Hubs;
public class NotificationHub : Hub
{
// Clients rufen hier keine Server-Methoden auf.
// Der Server pusht via IHubContext<NotificationHub>.
}

View File

@@ -0,0 +1,39 @@
namespace EngineeringSync.Service.Models;
public record CreateProjectRequest(
string Name,
string EngineeringPath,
string SimulationPath,
string FileExtensions,
bool IsActive = true,
bool BackupEnabled = true,
string? BackupPath = null,
int MaxBackupsPerFile = 0
);
public record UpdateProjectRequest(
string Name,
string EngineeringPath,
string SimulationPath,
string FileExtensions,
bool IsActive,
bool BackupEnabled = true,
string? BackupPath = null,
int MaxBackupsPerFile = 0
);
public record SyncRequest(List<Guid> ChangeIds);
public record IgnoreRequest(List<Guid> ChangeIds);
public record ScanRequest(
string EngineeringPath,
string SimulationPath,
string FileExtensions
);
public record ScanResultEntry(
string RelativePath,
string ChangeType,
long Size,
DateTime LastModified
);

View File

@@ -0,0 +1,16 @@
namespace EngineeringSync.Service.Models;
public record FileEvent(
Guid ProjectId,
string FullPath,
string RelativePath,
FileEventType EventType,
string? OldRelativePath = null
);
public enum FileEventType
{
CreatedOrChanged,
Renamed,
Deleted
}

View File

@@ -0,0 +1,151 @@
using System.Text.Json;
using EngineeringSync.Infrastructure;
using EngineeringSync.Service.Api;
using EngineeringSync.Service.Hubs;
using EngineeringSync.Service.Services;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseWindowsService();
// Datenbankpfad: neben der .exe oder im AppData-Ordner (dev: aktuelles Verzeichnis)
var dbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"EngineeringSync", "engineeringsync.db");
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
builder.Services.AddInfrastructure(dbPath);
builder.Services.AddSignalR();
builder.Services.AddSingleton<WatcherService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<WatcherService>());
builder.Services.AddSingleton<SyncManager>();
// Kestrel nur auf localhost binden (kein öffentlicher Port)
builder.WebHost.UseUrls("http://localhost:5050");
var app = builder.Build();
// Datenbankmigrationen beim Start
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
// WAL-Modus aktivieren (muss nach dem Öffnen der DB gesetzt werden)
await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;");
// First-Run-Config laden (falls vorhanden)
var watcher = scope.ServiceProvider.GetRequiredService<WatcherService>();
await ProcessFirstRunConfigAsync(db, watcher);
}
async Task ProcessFirstRunConfigAsync(AppDbContext db, WatcherService watcher)
{
var configPath = Path.Combine(AppContext.BaseDirectory, "firstrun-config.json");
if (!File.Exists(configPath))
return;
try
{
var json = await File.ReadAllTextAsync(configPath);
var root = JsonDocument.Parse(json);
var firstRunElement = root.RootElement.GetProperty("FirstRun");
var projectName = firstRunElement.GetProperty("ProjectName").GetString() ?? "Imported Project";
var engineeringPath = firstRunElement.GetProperty("EngineeringPath").GetString() ?? "";
var simulationPath = firstRunElement.GetProperty("SimulationPath").GetString() ?? "";
var fileExtensionsRaw = firstRunElement.GetProperty("FileExtensions").GetString() ?? "";
var watchAllFiles = firstRunElement.TryGetProperty("WatchAllFiles", out var watchAllElement)
? watchAllElement.GetBoolean()
: false;
// Wenn WatchAllFiles=true oder FileExtensions="*", dann leerer String (alle Dateien)
var fileExtensions = (watchAllFiles || fileExtensionsRaw == "*") ? "" : fileExtensionsRaw;
// Backup-Einstellungen (optional, mit Standardwerten für Rückwärtskompatibilität)
var backupEnabled = true;
string? backupPath = null;
var maxBackupsPerFile = 0;
if (root.RootElement.TryGetProperty("Backup", out var backupElement))
{
if (backupElement.TryGetProperty("BackupEnabled", out var be))
backupEnabled = be.GetBoolean();
if (backupElement.TryGetProperty("BackupPath", out var bp) &&
bp.ValueKind != JsonValueKind.Null)
backupPath = bp.GetString();
if (backupElement.TryGetProperty("MaxBackupsPerFile", out var mb))
maxBackupsPerFile = mb.GetInt32();
}
// Nur erstellen, wenn die Verzeichnisse existieren
if (!Directory.Exists(engineeringPath) || !Directory.Exists(simulationPath))
{
System.Console.WriteLine(
$"[FirstRunConfig] WARNUNG: Engineering- oder Simulations-Pfad existiert nicht. " +
$"Engineering={engineeringPath}, Simulation={simulationPath}");
return;
}
// Prüfen: Existiert bereits ein Projekt mit diesem Namen?
var existingProject = await db.Projects
.FirstOrDefaultAsync(p => p.Name == projectName);
if (existingProject != null)
{
System.Console.WriteLine(
$"[FirstRunConfig] Projekt '{projectName}' existiert bereits. Überspringe Import.");
return;
}
// Neue ProjectConfig erstellen
var project = new EngineeringSync.Domain.Entities.ProjectConfig
{
Name = projectName,
EngineeringPath = engineeringPath,
SimulationPath = simulationPath,
FileExtensions = fileExtensions,
IsActive = true,
CreatedAt = DateTime.UtcNow,
BackupEnabled = backupEnabled,
BackupPath = backupPath,
MaxBackupsPerFile = maxBackupsPerFile
};
db.Projects.Add(project);
await db.SaveChangesAsync();
System.Console.WriteLine(
$"[FirstRunConfig] Projekt '{projectName}' wurde importiert und aktiviert. " +
$"FileExtensions='{fileExtensions}'");
// Initialer Scan: Unterschiede zwischen Engineering- und Simulations-Ordner erkennen
try { await watcher.ScanExistingFilesAsync(project); }
catch (Exception ex)
{
System.Console.WriteLine(
$"[FirstRunConfig] FEHLER beim initialen Scan für '{projectName}': {ex.Message}");
}
// Konfigurationsdatei umbenennen, damit sie beim nächsten Start nicht wieder verarbeitet wird
// (WatcherService.ExecuteAsync lädt das Projekt automatisch aus der DB beim Host-Start)
var processedPath = configPath + ".processed";
if (File.Exists(processedPath))
File.Delete(processedPath);
File.Move(configPath, processedPath);
System.Console.WriteLine($"[FirstRunConfig] Konfigurationsdatei verarbeitet: {configPath} → {processedPath}");
}
catch (Exception ex)
{
System.Console.WriteLine(
$"[FirstRunConfig] FEHLER beim Verarbeiten der Erstkonfiguration: {ex.Message}");
}
}
app.MapHub<NotificationHub>("/notifications");
app.MapProjectsApi();
app.MapChangesApi();
app.Run();

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"EngineeringSync.Service": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,14 @@
using System.Security.Cryptography;
namespace EngineeringSync.Service.Services;
public static class FileHasher
{
public static async Task<string> ComputeAsync(string filePath, CancellationToken ct = default)
{
await using var stream = new FileStream(filePath,
FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, true);
var hash = await SHA256.HashDataAsync(stream, ct);
return Convert.ToHexString(hash);
}
}

View File

@@ -0,0 +1,143 @@
using EngineeringSync.Domain.Entities;
using EngineeringSync.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace EngineeringSync.Service.Services;
public class SyncManager(IDbContextFactory<AppDbContext> dbFactory, ILogger<SyncManager> logger)
{
public async Task<SyncResult> SyncAsync(IEnumerable<Guid> changeIds, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var ids = changeIds.ToList();
var changes = await db.PendingChanges
.Include(c => c.Project)
.Where(c => ids.Contains(c.Id) && c.Status == ChangeStatus.Pending)
.ToListAsync(ct);
int success = 0, failed = 0;
foreach (var change in changes)
{
try
{
await ProcessChangeAsync(change, ct);
change.Status = ChangeStatus.Synced;
change.SyncedAt = DateTime.UtcNow;
success++;
}
catch (Exception ex)
{
logger.LogError(ex, "Fehler beim Sync von {Path}", change.RelativePath);
failed++;
}
}
await db.SaveChangesAsync(ct);
return new SyncResult(success, failed);
}
public async Task IgnoreAsync(IEnumerable<Guid> changeIds, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var ids = changeIds.ToList();
await db.PendingChanges
.Where(c => ids.Contains(c.Id) && c.Status == ChangeStatus.Pending)
.ExecuteUpdateAsync(s => s.SetProperty(c => c.Status, ChangeStatus.Ignored), ct);
}
private async Task ProcessChangeAsync(PendingChange change, CancellationToken ct)
{
var project = change.Project;
var sourcePath = SafeCombine(project.EngineeringPath, change.RelativePath);
var targetPath = SafeCombine(project.SimulationPath, change.RelativePath);
if (change.ChangeType == ChangeType.Deleted)
{
if (File.Exists(targetPath))
{
BackupFile(targetPath, project);
File.Delete(targetPath);
}
return;
}
if (change.ChangeType == ChangeType.Renamed)
{
if (!string.IsNullOrEmpty(change.OldRelativePath))
{
var oldTargetPath = SafeCombine(project.SimulationPath, change.OldRelativePath);
if (File.Exists(oldTargetPath))
File.Delete(oldTargetPath);
}
}
if (!File.Exists(sourcePath))
throw new FileNotFoundException($"Quelldatei nicht gefunden: {sourcePath}");
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
if (File.Exists(targetPath))
BackupFile(targetPath, project);
await Task.Run(() => File.Copy(sourcePath, targetPath, overwrite: true), ct);
}
private void BackupFile(string targetPath, ProjectConfig project)
{
if (!project.BackupEnabled)
return;
var backupDir = project.BackupPath ?? Path.GetDirectoryName(targetPath)!;
Directory.CreateDirectory(backupDir);
var nameWithoutExt = Path.GetFileNameWithoutExtension(targetPath);
var ext = Path.GetExtension(targetPath);
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var backupPath = Path.Combine(backupDir, $"{nameWithoutExt}_{timestamp}{ext}.bak");
try
{
File.Move(targetPath, backupPath);
}
catch (IOException)
{
File.Copy(targetPath, backupPath, overwrite: false);
File.Delete(targetPath);
}
if (project.MaxBackupsPerFile > 0)
CleanupOldBackups(backupDir, nameWithoutExt, ext, project.MaxBackupsPerFile);
}
private void CleanupOldBackups(string backupDir, string baseName, string ext, int max)
{
try
{
var pattern = $"{baseName}_*{ext}.bak";
var backups = Directory.GetFiles(backupDir, pattern)
.OrderBy(f => Path.GetFileName(f))
.ToList();
while (backups.Count > max)
{
File.Delete(backups[0]);
backups.RemoveAt(0);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Cleanup alter Backups fehlgeschlagen in {Dir}", backupDir);
}
}
private static string SafeCombine(string basePath, string relativePath)
{
var fullPath = Path.GetFullPath(Path.Combine(basePath, relativePath));
var normalizedBase = Path.GetFullPath(basePath);
if (!fullPath.StartsWith(normalizedBase + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
!fullPath.Equals(normalizedBase, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Path traversal detected: {relativePath}");
return fullPath;
}
}
public record SyncResult(int Success, int Failed);

View File

@@ -0,0 +1,434 @@
using System.Threading.Channels;
using EngineeringSync.Domain.Constants;
using EngineeringSync.Domain.Entities;
using EngineeringSync.Infrastructure;
using EngineeringSync.Service.Hubs;
using EngineeringSync.Service.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace EngineeringSync.Service.Services;
/// <summary>
/// Verwaltet FileSystemWatcher-Instanzen für alle aktiven Projekte.
/// Nutzt ein Channel als Puffer zwischen dem schnellen FSW-Event-Thread
/// und dem langsamen DB-Schreib-Thread (Debouncing).
/// </summary>
public sealed class WatcherService(
IDbContextFactory<AppDbContext> dbFactory,
IHubContext<NotificationHub> hub,
ILogger<WatcherService> logger) : BackgroundService
{
// Unbounded Channel: FSW-Events kommen schnell, Verarbeitung ist langsam
private readonly Channel<FileEvent> _channel =
Channel.CreateUnbounded<FileEvent>(new UnboundedChannelOptions { SingleReader = true });
// Watcher pro Projekt-ID wird dynamisch verwaltet
private readonly Dictionary<Guid, FileSystemWatcher[]> _watchers = [];
private readonly SemaphoreSlim _watcherLock = new(1, 1);
private readonly ILogger<WatcherService> _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Alle aktiven Projekte beim Start laden
List<ProjectConfig> projects;
await using (var db = await dbFactory.CreateDbContextAsync(stoppingToken))
{
projects = await db.Projects.Where(p => p.IsActive).ToListAsync(stoppingToken);
foreach (var project in projects)
await StartWatchingAsync(project);
}
// Initialer Scan: Engineering- vs. Simulations-Ordner vergleichen
foreach (var project in projects)
{
try { await ScanExistingFilesAsync(project, stoppingToken); }
catch (Exception ex)
{
_logger.LogError(ex, "Initialer Scan fehlgeschlagen für '{Name}'", project.Name);
}
}
// Channel-Consumer läuft, bis der Service gestoppt wird
await ConsumeChannelAsync(stoppingToken);
}
/// <summary>Startet Watcher für ein Projekt. Idempotent stoppt alten Watcher zuerst.</summary>
public async Task StartWatchingAsync(ProjectConfig project)
{
await _watcherLock.WaitAsync();
try
{
StopWatchingInternal(project.Id);
if (!project.IsActive || !Directory.Exists(project.EngineeringPath))
return;
var extensions = project.GetExtensions().ToArray();
// Pro Dateiendung einen eigenen Watcher (FSW unterstützt nur ein Filter-Pattern)
var watchers = extensions.Select(ext => CreateWatcher(project, ext)).ToArray();
_watchers[project.Id] = watchers;
_logger.LogInformation("Watcher gestartet für Projekt '{Name}' ({Count} Extensions)",
project.Name, watchers.Length);
}
finally
{
_watcherLock.Release();
}
}
/// <summary>
/// Initialer Scan: Vergleicht Engineering- und Simulations-Ordner.
/// Erkennt Dateien, die im Engineering-Ordner vorhanden sind, aber im
/// Simulations-Ordner fehlen oder sich unterscheiden.
/// </summary>
public async Task ScanExistingFilesAsync(ProjectConfig project, CancellationToken ct = default)
{
if (!Directory.Exists(project.EngineeringPath)) return;
var extensions = project.GetExtensions().ToList();
// Wenn keine Erweiterungen angegeben oder "*" → alle Dateien scannen
var scanAllFiles = extensions.Count == 0 || (extensions.Count == 1 && extensions[0] == "*");
var engFiles = scanAllFiles
? Directory.EnumerateFiles(project.EngineeringPath, "*", SearchOption.AllDirectories).ToList()
: Directory.EnumerateFiles(project.EngineeringPath, "*", SearchOption.AllDirectories)
.Where(f => extensions.Contains(Path.GetExtension(f), StringComparer.OrdinalIgnoreCase))
.ToList();
if (engFiles.Count == 0) return;
await using var db = await dbFactory.CreateDbContextAsync(ct);
var newChanges = 0;
foreach (var engFile in engFiles)
{
var relativePath = Path.GetRelativePath(project.EngineeringPath, engFile);
var simFile = Path.Combine(project.SimulationPath, relativePath);
var engHash = await FileHasher.ComputeAsync(engFile, ct);
var engInfo = new FileInfo(engFile);
// FileRevision anlegen/aktualisieren
var revision = await db.FileRevisions
.FirstOrDefaultAsync(r => r.ProjectId == project.Id && r.RelativePath == relativePath, ct);
if (revision is null)
{
db.FileRevisions.Add(new FileRevision
{
ProjectId = project.Id,
RelativePath = relativePath,
FileHash = engHash,
Size = engInfo.Length,
LastModified = engInfo.LastWriteTimeUtc
});
}
else
{
revision.FileHash = engHash;
revision.Size = engInfo.Length;
revision.LastModified = engInfo.LastWriteTimeUtc;
}
// Prüfen: Existiert die Datei im Simulations-Ordner und ist sie identisch?
bool needsSync;
if (!File.Exists(simFile))
{
needsSync = true;
}
else
{
var simHash = await FileHasher.ComputeAsync(simFile, ct);
needsSync = engHash != simHash;
}
if (!needsSync) continue;
// Nur anlegen, wenn nicht bereits ein offener PendingChange existiert
var alreadyPending = await db.PendingChanges
.AnyAsync(c => c.ProjectId == project.Id
&& c.RelativePath == relativePath
&& c.Status == ChangeStatus.Pending, ct);
if (alreadyPending) continue;
db.PendingChanges.Add(new PendingChange
{
ProjectId = project.Id,
RelativePath = relativePath,
ChangeType = File.Exists(simFile) ? ChangeType.Modified : ChangeType.Created
});
newChanges++;
}
if (newChanges > 0)
{
await db.SaveChangesAsync(ct);
var totalPending = await db.PendingChanges
.CountAsync(c => c.ProjectId == project.Id && c.Status == ChangeStatus.Pending, ct);
await hub.Clients.All.SendAsync(HubMethodNames.ReceiveChangeNotification,
project.Id, project.Name, totalPending, ct);
_logger.LogInformation("Initialer Scan für '{Name}': {Count} Unterschied(e) erkannt",
project.Name, newChanges);
}
else
{
_logger.LogInformation("Initialer Scan für '{Name}': Ordner sind synchron", project.Name);
}
}
/// <summary>Stoppt und entfernt Watcher für ein Projekt.</summary>
public async Task StopWatchingAsync(Guid projectId)
{
await _watcherLock.WaitAsync();
try { StopWatchingInternal(projectId); }
finally { _watcherLock.Release(); }
}
private void StopWatchingInternal(Guid projectId)
{
if (!_watchers.Remove(projectId, out var old)) return;
foreach (var w in old) { w.EnableRaisingEvents = false; w.Dispose(); }
}
private FileSystemWatcher CreateWatcher(ProjectConfig project, string extension)
{
var watcher = new FileSystemWatcher(project.EngineeringPath)
{
Filter = extension == "*" ? "*.*" : $"*{extension}",
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size,
InternalBufferSize = 65536 // 64KB verhindert Buffer-Overflow bei vielen gleichzeitigen Events
};
// Lokale Kopie für Lambda-Capture
var projectId = project.Id;
var basePath = project.EngineeringPath;
watcher.Created += (_, e) => EnqueueEvent(projectId, basePath, e.FullPath, FileEventType.CreatedOrChanged);
watcher.Changed += (_, e) => EnqueueEvent(projectId, basePath, e.FullPath, FileEventType.CreatedOrChanged);
watcher.Deleted += (_, e) => EnqueueEvent(projectId, basePath, e.FullPath, FileEventType.Deleted);
watcher.Renamed += (_, e) => EnqueueRenamedEvent(projectId, basePath, e);
watcher.Error += (_, args) =>
{
_logger.LogWarning(args.GetException(), "FSW Buffer-Overflow für Projekt {Id} starte Re-Scan", projectId);
_ = Task.Run(async () =>
{
try
{
await ScanExistingFilesAsync(project);
}
catch (Exception ex)
{
_logger.LogError(ex, "Re-Scan nach Buffer-Overflow fehlgeschlagen für Projekt {Id}", projectId);
}
});
};
watcher.EnableRaisingEvents = true;
return watcher;
}
private void EnqueueEvent(Guid projectId, string basePath, string fullPath, FileEventType type)
{
var rel = Path.GetRelativePath(basePath, fullPath);
_channel.Writer.TryWrite(new FileEvent(projectId, fullPath, rel, type));
}
private void EnqueueRenamedEvent(Guid projectId, string basePath, RenamedEventArgs e)
{
var rel = Path.GetRelativePath(basePath, e.FullPath);
var oldRel = Path.GetRelativePath(basePath, e.OldFullPath);
_channel.Writer.TryWrite(new FileEvent(projectId, e.FullPath, rel, FileEventType.Renamed, oldRel));
}
/// <summary>
/// Debouncing-Consumer: Gruppiert Events nach (ProjectId, RelativePath) innerhalb
/// eines 2000ms-Fensters. Verhindert, dass eine Datei 10× verarbeitet wird.
/// </summary>
private async Task ConsumeChannelAsync(CancellationToken ct)
{
// Dictionary: Key=(ProjectId,RelativePath) → letztes Event
var pending = new Dictionary<(Guid, string), FileEvent>();
while (!ct.IsCancellationRequested)
{
// Warte auf erstes Event (blockierend)
if (!await _channel.Reader.WaitToReadAsync(ct)) break;
// Sammle alle sofort verfügbaren Events (nicht blockierend)
while (_channel.Reader.TryRead(out var evt))
pending[(evt.ProjectId, evt.RelativePath)] = evt; // neuester gewinnt
// 2s warten kommen in dieser Zeit weitere Events, werden sie im nächsten Batch verarbeitet
await Task.Delay(2000, ct);
// Noch mehr eingelaufene Events sammeln
while (_channel.Reader.TryRead(out var evt))
pending[(evt.ProjectId, evt.RelativePath)] = evt;
if (pending.Count == 0) continue;
var batch = pending.Values.ToList();
pending.Clear();
foreach (var evt in batch)
{
try { await ProcessEventAsync(evt, ct); }
catch (Exception ex)
{
_logger.LogError(ex, "Fehler beim Verarbeiten von {Path}", evt.RelativePath);
}
}
}
}
private async Task ProcessEventAsync(FileEvent evt, CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var project = await db.Projects.FindAsync([evt.ProjectId], ct);
if (project is null) return;
// Existierende Revision lesen
var revision = await db.FileRevisions
.FirstOrDefaultAsync(r => r.ProjectId == evt.ProjectId && r.RelativePath == evt.RelativePath, ct);
if (evt.EventType == FileEventType.Deleted)
{
await HandleDeleteAsync(db, project, revision, evt, ct);
return;
}
if (!File.Exists(evt.FullPath)) return; // Race condition: Datei schon weg
// Hash berechnen mit Retry-Logik für gesperrte Dateien
string newHash;
try
{
newHash = await ComputeHashWithRetryAsync(evt.FullPath, ct);
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Hash-Berechnung für {Path} fehlgeschlagen nach Retrys überspringe Event", evt.RelativePath);
return;
}
if (revision is not null && revision.FileHash == newHash) return; // Keine echte Änderung
var info = new FileInfo(evt.FullPath);
// FileRevision anlegen oder aktualisieren
if (revision is null)
{
db.FileRevisions.Add(new FileRevision
{
ProjectId = evt.ProjectId,
RelativePath = evt.RelativePath,
FileHash = newHash,
Size = info.Length,
LastModified = info.LastWriteTimeUtc
});
}
else
{
revision.FileHash = newHash;
revision.Size = info.Length;
revision.LastModified = info.LastWriteTimeUtc;
if (evt.EventType == FileEventType.Renamed)
revision.RelativePath = evt.RelativePath;
}
// PendingChange schreiben
var changeType = evt.EventType switch
{
FileEventType.Renamed => ChangeType.Renamed,
_ when revision is null => ChangeType.Created,
_ => ChangeType.Modified
};
db.PendingChanges.Add(new PendingChange
{
ProjectId = evt.ProjectId,
RelativePath = evt.RelativePath,
ChangeType = changeType,
OldRelativePath = evt.OldRelativePath
});
await db.SaveChangesAsync(ct);
// Alle SignalR-Clients benachrichtigen
var count = await db.PendingChanges
.CountAsync(c => c.ProjectId == evt.ProjectId && c.Status == ChangeStatus.Pending, ct);
await hub.Clients.All.SendAsync(HubMethodNames.ReceiveChangeNotification,
evt.ProjectId, project.Name, count, ct);
_logger.LogInformation("[{Type}] {Path} in Projekt '{Name}'",
changeType, evt.RelativePath, project.Name);
}
private async Task HandleDeleteAsync(AppDbContext db, ProjectConfig project,
FileRevision? revision, FileEvent evt, CancellationToken ct)
{
if (revision is not null)
db.FileRevisions.Remove(revision);
db.PendingChanges.Add(new PendingChange
{
ProjectId = evt.ProjectId,
RelativePath = evt.RelativePath,
ChangeType = ChangeType.Deleted
});
await db.SaveChangesAsync(ct);
var count = await db.PendingChanges
.CountAsync(c => c.ProjectId == evt.ProjectId && c.Status == ChangeStatus.Pending, ct);
await hub.Clients.All.SendAsync(HubMethodNames.ReceiveChangeNotification,
evt.ProjectId, project.Name, count, ct);
}
/// <summary>
/// Berechnet den Hash einer Datei mit Retry-Logik.
/// Versucht bis zu 3 Mal, mit exponentieller Backoff-Wartezeit.
/// Wirft IOException, wenn alle Versuche scheitern.
/// </summary>
private async Task<string> ComputeHashWithRetryAsync(string fullPath, CancellationToken ct)
{
const int maxAttempts = 3;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
try
{
return await FileHasher.ComputeAsync(fullPath, ct);
}
catch (IOException) when (attempt < maxAttempts - 1)
{
var delaySeconds = 2 * (attempt + 1);
_logger.LogDebug("Hash-Berechnung für {Path} fehlgeschlagen (Versuch {Attempt}), warte {Seconds}s...",
fullPath, attempt + 1, delaySeconds);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
}
}
// Wenn alle Versuche fehlschlagen, IOException werfen
throw new IOException($"Hash-Berechnung für {fullPath} fehlgeschlagen nach {maxAttempts} Versuchen");
}
public override void Dispose()
{
// Channel-Writer schließen, um ConsumeChannelAsync zum Beenden zu bringen
_channel.Writer.TryComplete();
foreach (var watchers in _watchers.Values)
foreach (var w in watchers) w.Dispose();
_watcherLock.Dispose();
base.Dispose();
}
}

View File

@@ -0,0 +1,16 @@
namespace EngineeringSync.Service;
public class Worker(ILogger<Worker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
await Task.Delay(1000, stoppingToken);
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,17 @@
<Application x:Class="EngineeringSync.Setup.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:EngineeringSync.Setup.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/WizardStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<conv:BoolToVisibilityConverter x:Key="BoolToVisConverter"/>
<conv:BoolToInverseVisibilityConverter x:Key="BoolToInvVisConverter"/>
<conv:LastStepLabelConverter x:Key="LastStepLabelConverter"/>
<conv:StringToVisibilityConverter x:Key="StringToVisConverter"/>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,82 @@
using System.Configuration;
using System.Data;
using System.Diagnostics;
using System.IO;
using System.ServiceProcess;
using System.Windows;
using Microsoft.Win32;
namespace EngineeringSync.Setup;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
if (e.Args.Length > 0 && e.Args[0].Equals("/uninstall", StringComparison.OrdinalIgnoreCase))
{
RunUninstall();
Shutdown();
return;
}
var window = new Views.WizardWindow();
window.Show();
}
private void RunUninstall()
{
var serviceName = "EngineeringSync";
try
{
var sc = new ServiceController(serviceName);
if (sc.Status != ServiceControllerStatus.Stopped)
{
sc.Stop();
sc.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(10));
}
}
catch { }
try
{
Process.Start(new ProcessStartInfo
{
FileName = "sc",
Arguments = "delete EngineeringSync",
UseShellExecute = false,
CreateNoWindow = true
})?.WaitForExit();
}
catch { }
try
{
using var runKey = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", true);
runKey?.DeleteValue("EngineeringSync.TrayApp", false);
}
catch { }
try
{
Registry.LocalMachine.DeleteSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\EngineeringSync", false);
}
catch { }
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory);
var linkPath = Path.Combine(desktop, "EngineeringSync.lnk");
if (File.Exists(linkPath))
File.Delete(linkPath);
var startMenu = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs", "EngineeringSync");
if (Directory.Exists(startMenu))
Directory.Delete(startMenu, true);
var msg = "EngineeringSync wurde erfolgreich deinstalliert.";
MessageBox.Show(msg, "Deinstallation", MessageBoxButton.OK, MessageBoxImage.Information);
}
}

View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -0,0 +1,40 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace EngineeringSync.Setup.Converters;
public class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object v, Type t, object p, CultureInfo c) =>
v is true ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object v, Type t, object p, CultureInfo c) =>
throw new NotSupportedException();
}
public class BoolToInverseVisibilityConverter : IValueConverter
{
public object Convert(object v, Type t, object p, CultureInfo c) =>
v is true ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object v, Type t, object p, CultureInfo c) =>
throw new NotSupportedException();
}
/// <summary>
/// Gibt "Jetzt installieren" wenn true (letzter normaler Schritt), sonst "Weiter".
/// </summary>
public class LastStepLabelConverter : IValueConverter
{
public object Convert(object v, Type t, object p, CultureInfo c) =>
v is true ? "Jetzt installieren" : "Weiter";
public object ConvertBack(object v, Type t, object p, CultureInfo c) =>
throw new NotSupportedException();
}
public class StringToVisibilityConverter : IValueConverter
{
public object Convert(object v, Type t, object p, CultureInfo c) =>
string.IsNullOrEmpty(v as string) ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object v, Type t, object p, CultureInfo c) =>
throw new NotSupportedException();
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<AssemblyName>EngineeringSync.Setup</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\setup-icon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\setup-icon.ico" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
<Window x:Class="EngineeringSync.Setup.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:EngineeringSync.Setup"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,23 @@
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace EngineeringSync.Setup;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,304 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using EngineeringSync.Setup.ViewModels;
using Microsoft.Win32;
namespace EngineeringSync.Setup.Services;
/// <summary>
/// Führt alle Installationsschritte aus. Gibt Fortschritt und Log-Meldungen
/// über Events zurück, damit die UI in Echtzeit aktualisiert werden kann.
/// </summary>
public class InstallerService(WizardState state)
{
public event Action<int, string>? Progress;
public event Action<string>? LogMessage;
private void Report(int percent, string step, string? log = null)
{
Progress?.Invoke(percent, step);
LogMessage?.Invoke(log ?? step);
}
/// <summary>Hauptinstallationsablauf läuft auf einem Background-Thread.</summary>
public async Task InstallAsync()
{
await Task.Run(async () =>
{
// 1. Installationsverzeichnis anlegen
Report(5, "Installationsverzeichnis anlegen...");
Directory.CreateDirectory(state.InstallPath);
await Delay();
// 2. Programmdateien kopieren
Report(15, "Programmdateien werden kopiert...");
await CopyApplicationFilesAsync();
// 3. Datenbank-Verzeichnis anlegen
Report(30, "Datenbankverzeichnis anlegen...");
var dbDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"EngineeringSync");
Directory.CreateDirectory(dbDir);
Report(33, "Datenbankverzeichnis angelegt.", $"Datenbank-Pfad: {dbDir}");
await Delay();
// 4. Erstkonfigurations-Datei schreiben
Report(40, "Erstkonfiguration wird gespeichert...");
WriteFirstRunConfig(dbDir);
await Delay();
// 5. Bestehenden Dienst stoppen & deinstallieren (Upgrade-Szenario)
Report(50, "Vorherige Installation wird geprüft...");
await StopAndRemoveExistingServiceAsync();
await Delay();
// 6. Windows Service registrieren
Report(60, "Windows-Dienst wird registriert...");
var serviceExe = Path.Combine(state.InstallPath, "EngineeringSync.Service.exe");
await RegisterWindowsServiceAsync(serviceExe);
await Delay();
// 7. Autostart für TrayApp
if (state.AutoStartTrayApp)
{
Report(70, "TrayApp-Autostart wird konfiguriert...");
ConfigureTrayAppAutostart();
await Delay();
}
// 8. Verknüpfungen erstellen
Report(78, "Verknüpfungen werden erstellt...");
if (state.CreateDesktopShortcut) CreateShortcut(ShortcutTarget.Desktop);
if (state.CreateStartMenuEntry) CreateShortcut(ShortcutTarget.StartMenu);
await Delay();
// 9. Add/Remove Programs Eintrag
Report(85, "Deinstallations-Eintrag wird angelegt...");
RegisterUninstallEntry();
await Delay();
// 10. Dienst starten
if (state.AutoStartService)
{
Report(92, "Windows-Dienst wird gestartet...");
await StartServiceAsync();
}
Report(100, "Installation abgeschlossen.", "✓ EngineeringSync wurde erfolgreich installiert.");
});
}
// ── Hilfsmethoden ─────────────────────────────────────────────────
private async Task CopyApplicationFilesAsync()
{
var sourceDir = Path.GetFullPath(AppContext.BaseDirectory).TrimEnd(Path.DirectorySeparatorChar);
var targetDir = Path.GetFullPath(state.InstallPath).TrimEnd(Path.DirectorySeparatorChar);
// Wenn das Setup bereits aus dem Zielverzeichnis läuft (z.B. nach Inno-Setup-Installation),
// wurden die Dateien bereits kopiert nichts zu tun.
if (string.Equals(sourceDir, targetDir, StringComparison.OrdinalIgnoreCase))
{
LogMessage?.Invoke(" Dateien bereits installiert (Inno-Setup) - Kopieren wird übersprungen.");
return;
}
var patterns = new[] { "*.exe", "*.dll", "*.json", "*.pdb" };
var allFiles = patterns
.SelectMany(p => Directory.GetFiles(sourceDir, p))
.Distinct()
.ToList();
for (int i = 0; i < allFiles.Count; i++)
{
var src = allFiles[i];
var dest = Path.Combine(targetDir, Path.GetFileName(src));
File.Copy(src, dest, overwrite: true);
LogMessage?.Invoke($" Kopiert: {Path.GetFileName(src)}");
await Task.Delay(30); // Kurze Pause für UI-Responsivität
}
}
private void WriteFirstRunConfig(string dbDir)
{
var config = new
{
FirstRun = new
{
ProjectName = state.ProjectName,
EngineeringPath = state.EngineeringPath,
SimulationPath = state.SimulationPath,
FileExtensions = state.WatchAllFiles ? "*" : state.FileExtensions,
WatchAllFiles = state.WatchAllFiles
},
Backup = new // NEU
{
BackupEnabled = state.BackupEnabled,
BackupPath = state.BackupUseCustomPath ? state.BackupCustomPath : (string?)null,
MaxBackupsPerFile = state.MaxBackupsPerFile
}
};
var json = JsonSerializer.Serialize(config,
new JsonSerializerOptions { WriteIndented = true });
var configPath = Path.Combine(state.InstallPath, "firstrun-config.json");
File.WriteAllText(configPath, json);
LogMessage?.Invoke($" Konfiguration geschrieben: {configPath}");
}
private async Task StopAndRemoveExistingServiceAsync()
{
try
{
var stopResult = await RunProcessAsync("sc", "stop EngineeringSync");
if (stopResult.exitCode == 0)
{
LogMessage?.Invoke(" Bestehender Dienst gestoppt.");
await Task.Delay(1500); // Warten bis Dienst wirklich gestoppt
}
await RunProcessAsync("sc", "delete EngineeringSync");
LogMessage?.Invoke(" Bestehender Dienst entfernt.");
}
catch { /* Kein vorheriger Dienst OK */ }
}
private async Task RegisterWindowsServiceAsync(string exePath)
{
if (!File.Exists(exePath))
{
LogMessage?.Invoke($" WARNUNG: Service-EXE nicht gefunden: {exePath}");
LogMessage?.Invoke(" (Im Entwicklungsmodus Service-Registrierung übersprungen)");
return;
}
var startType = state.AutoStartService ? "auto" : "demand";
var args = $"create EngineeringSync binPath= \"{exePath}\" " +
$"start= {startType} " +
$"DisplayName= \"EngineeringSync Watcher Service\"";
var (exitCode, output, error) = await RunProcessAsync("sc", args);
if (exitCode == 0)
LogMessage?.Invoke(" ✓ Windows-Dienst registriert.");
else
{
LogMessage?.Invoke($" FEHLER bei sc create (Code {exitCode}): {error}");
throw new InvalidOperationException($"Service-Registrierung fehlgeschlagen: {error}");
}
}
private void ConfigureTrayAppAutostart()
{
var trayExe = Path.Combine(state.InstallPath, "EngineeringSync.TrayApp.exe");
if (!File.Exists(trayExe))
{
LogMessage?.Invoke(" TrayApp nicht gefunden Autostart übersprungen.");
return;
}
using var key = Registry.CurrentUser.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", writable: true)!;
key.SetValue("EngineeringSync.TrayApp", $"\"{trayExe}\"");
LogMessage?.Invoke(" ✓ TrayApp-Autostart in Registry eingetragen.");
}
private enum ShortcutTarget { Desktop, StartMenu }
private void CreateShortcut(ShortcutTarget target)
{
var trayExe = Path.Combine(state.InstallPath, "EngineeringSync.TrayApp.exe");
var dir = target == ShortcutTarget.Desktop
? Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory)
: Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs", "EngineeringSync");
Directory.CreateDirectory(dir);
var linkPath = Path.Combine(dir, "EngineeringSync.lnk");
CreateWindowsShortcut(linkPath, trayExe, state.InstallPath, "EngineeringSync Tray App");
LogMessage?.Invoke($" ✓ Verknüpfung erstellt: {linkPath}");
}
private static void CreateWindowsShortcut(string linkPath, string targetPath,
string workingDir, string description)
{
// WScript.Shell COM-Interop für Verknüpfungs-Erstellung
var type = Type.GetTypeFromProgID("WScript.Shell")!;
dynamic shell = Activator.CreateInstance(type)!;
var shortcut = shell.CreateShortcut(linkPath);
shortcut.TargetPath = targetPath;
shortcut.WorkingDirectory = workingDir;
shortcut.Description = description;
shortcut.Save();
Marshal.FinalReleaseComObject(shortcut);
Marshal.FinalReleaseComObject(shell);
}
private void RegisterUninstallEntry()
{
using var key = Registry.LocalMachine.CreateSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\EngineeringSync");
var uninstallExe = Path.Combine(state.InstallPath, "EngineeringSync.Setup.exe");
key.SetValue("DisplayName", "EngineeringSync");
key.SetValue("DisplayVersion", "1.0.0");
key.SetValue("Publisher", "EngineeringSync");
key.SetValue("InstallLocation", state.InstallPath);
key.SetValue("UninstallString", $"\"{uninstallExe}\" /uninstall");
key.SetValue("NoModify", 1, RegistryValueKind.DWord);
key.SetValue("NoRepair", 1, RegistryValueKind.DWord);
key.SetValue("EstimatedSize", 45000, RegistryValueKind.DWord);
key.SetValue("DisplayIcon", Path.Combine(state.InstallPath, "EngineeringSync.TrayApp.exe"));
LogMessage?.Invoke(" ✓ Deinstallations-Eintrag in Registry angelegt.");
}
private async Task StartServiceAsync()
{
var (exitCode, _, error) = await RunProcessAsync("sc", "start EngineeringSync");
if (exitCode == 0)
LogMessage?.Invoke(" ✓ Windows-Dienst gestartet.");
else
LogMessage?.Invoke($" WARNUNG: Dienst konnte nicht gestartet werden ({error})");
}
public void LaunchTrayApp()
{
var trayExe = Path.Combine(state.InstallPath, "EngineeringSync.TrayApp.exe");
if (File.Exists(trayExe))
Process.Start(new ProcessStartInfo(trayExe) { UseShellExecute = true });
}
private static async Task<(int exitCode, string output, string error)> RunProcessAsync(
string fileName, string arguments)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
}
};
process.Start();
var output = await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, output.Trim(), error.Trim());
}
private static Task Delay() => Task.Delay(400);
}

View File

@@ -0,0 +1,361 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ═══════════════════════════════════════════
FARBEN & BRUSHES
═══════════════════════════════════════════ -->
<Color x:Key="AccentColor">#0078D4</Color>
<Color x:Key="AccentHoverColor">#106EBE</Color>
<Color x:Key="AccentPressColor">#005A9E</Color>
<Color x:Key="SidebarTopColor">#1B1B2F</Color>
<Color x:Key="SidebarBottomColor">#16213E</Color>
<Color x:Key="SuccessColor">#107C10</Color>
<Color x:Key="ErrorColor">#C42B1C</Color>
<Color x:Key="WarningColor">#CA5010</Color>
<Color x:Key="SurfaceColor">#FAFAFA</Color>
<Color x:Key="BorderColor">#E0E0E0</Color>
<Color x:Key="TextPrimaryColor">#1A1A1A</Color>
<Color x:Key="TextSecondaryColor">#5F5F5F</Color>
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/>
<SolidColorBrush x:Key="AccentHoverBrush" Color="{StaticResource AccentHoverColor}"/>
<SolidColorBrush x:Key="AccentPressBrush" Color="{StaticResource AccentPressColor}"/>
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}"/>
<SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}"/>
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}"/>
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}"/>
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/>
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{StaticResource TextPrimaryColor}"/>
<SolidColorBrush x:Key="TextSecondaryBrush" Color="{StaticResource TextSecondaryColor}"/>
<LinearGradientBrush x:Key="SidebarGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="{StaticResource SidebarTopColor}" Offset="0"/>
<GradientStop Color="{StaticResource SidebarBottomColor}" Offset="1"/>
</LinearGradientBrush>
<!-- ═══════════════════════════════════════════
TYPOGRAPHY
═══════════════════════════════════════════ -->
<FontFamily x:Key="UiFont">Segoe UI</FontFamily>
<!-- ═══════════════════════════════════════════
BUTTON STYLES
═══════════════════════════════════════════ -->
<!-- Primärer Akzent-Button -->
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="24,10"/>
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="border"
Background="{TemplateBinding Background}"
CornerRadius="6"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background"
Value="{StaticResource AccentHoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background"
Value="{StaticResource AccentPressBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="border" Property="Background" Value="#BDBDBD"/>
<Setter Property="Foreground" Value="#888888"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Sekundärer Button (Ghost) -->
<Style x:Key="SecondaryButtonStyle" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="20,10"/>
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="#F5F5F5"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border" Property="Background" Value="#EBEBEB"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="#BDBDBD"/>
<Setter TargetName="border" Property="BorderBrush" Value="#E0E0E0"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Icon-Button für Folder-Browse -->
<Style x:Key="IconButtonStyle" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="Width" Value="36"/>
<Setter Property="Height" Value="36"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="#F0F7FF"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══════════════════════════════════════════
TEXTBOX STYLE
═══════════════════════════════════════════ -->
<Style x:Key="ModernTextBoxStyle" TargetType="TextBox">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="Background" Value="White"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="6">
<ScrollViewer x:Name="PART_ContentHost" Margin="2,0"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="border" Property="BorderBrush"
Value="{StaticResource AccentBrush}"/>
<Setter TargetName="border" Property="BorderThickness" Value="2"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="border" Property="Background" Value="#F5F5F5"/>
<Setter Property="Foreground" Value="#BDBDBD"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══════════════════════════════════════════
CHECKBOX STYLE
═══════════════════════════════════════════ -->
<Style x:Key="ModernCheckBoxStyle" TargetType="CheckBox">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Border x:Name="box"
Width="18" Height="18"
CornerRadius="4"
BorderThickness="2"
BorderBrush="{StaticResource BorderBrush}"
Background="White"
Margin="0,0,8,0">
<TextBlock x:Name="check"
Text="&#xE73E;"
FontFamily="Segoe MDL2 Assets"
FontSize="11"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="Collapsed"/>
</Border>
<ContentPresenter VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="box" Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="box" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="check" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="box" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══════════════════════════════════════════
LABELS & TYPOGRAPHY
═══════════════════════════════════════════ -->
<Style x:Key="PageTitleStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontSize" Value="24"/>
<Setter Property="FontWeight" Value="Light"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
<Style x:Key="PageSubtitleStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Margin" Value="0,0,0,24"/>
<Setter Property="LineHeight" Value="20"/>
</Style>
<Style x:Key="FieldLabelStyle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}"/>
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
<!-- ═══════════════════════════════════════════
SIDEBAR STEP ITEM
═══════════════════════════════════════════ -->
<Style x:Key="StepItemStyle" TargetType="ContentControl">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContentControl">
<Grid Margin="0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="36"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Kreis-Indikator -->
<Border x:Name="circle"
Width="28" Height="28"
CornerRadius="14"
Background="#2A3050"
HorizontalAlignment="Center">
<TextBlock x:Name="iconText"
Text="{Binding Icon}"
FontFamily="Segoe MDL2 Assets"
FontSize="12"
Foreground="#8899BB"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- Titel -->
<TextBlock x:Name="titleText"
Grid.Column="1"
Text="{Binding Title}"
FontFamily="{StaticResource UiFont}"
FontSize="12"
Foreground="#8899BB"
VerticalAlignment="Center"
Margin="8,0,0,0"/>
</Grid>
<ControlTemplate.Triggers>
<!-- Aktiver Schritt -->
<DataTrigger Binding="{Binding IsActive}" Value="True">
<Setter TargetName="circle" Property="Background" Value="{StaticResource AccentBrush}"/>
<Setter TargetName="iconText" Property="Foreground" Value="White"/>
<Setter TargetName="titleText" Property="Foreground" Value="White"/>
<Setter TargetName="titleText" Property="FontWeight" Value="SemiBold"/>
</DataTrigger>
<!-- Abgeschlossener Schritt -->
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
<Setter TargetName="circle" Property="Background" Value="#107C10"/>
<Setter TargetName="iconText" Property="Text" Value="&#xE73E;"/>
<Setter TargetName="iconText" Property="Foreground" Value="White"/>
<Setter TargetName="titleText" Property="Foreground" Value="#88CCAA"/>
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ═══════════════════════════════════════════
INFO CARD (für Zusammenfassung etc.)
═══════════════════════════════════════════ -->
<Style x:Key="InfoCardStyle" TargetType="Border">
<Setter Property="Background" Value="White"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="Margin" Value="0,0,0,12"/>
</Style>
<!-- ═══════════════════════════════════════════
PROGRESS BAR
═══════════════════════════════════════════ -->
<Style x:Key="ModernProgressBarStyle" TargetType="ProgressBar">
<Setter Property="Height" Value="6"/>
<Setter Property="Background" Value="#E0E0E0"/>
<Setter Property="Foreground" Value="{StaticResource AccentBrush}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid>
<Border Background="{TemplateBinding Background}"
CornerRadius="3"/>
<Border x:Name="PART_Track" CornerRadius="3" ClipToBounds="True">
<Border x:Name="PART_Indicator"
Background="{TemplateBinding Foreground}"
CornerRadius="3"
HorizontalAlignment="Left"/>
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,36 @@
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
namespace EngineeringSync.Setup.ViewModels;
/// <summary>
/// Zentrales Datenmodell das durch alle Wizard-Schritte weitergereicht wird.
/// </summary>
public partial class WizardState : ObservableObject
{
// --- Installationspfad ---
[ObservableProperty]
private string _installPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
"EngineeringSync");
// --- Erstes Projekt ---
[ObservableProperty] private string _projectName = "Mein Projekt";
[ObservableProperty] private string _engineeringPath = string.Empty;
[ObservableProperty] private string _simulationPath = string.Empty;
[ObservableProperty] private bool _watchAllFiles = false;
[ObservableProperty] private string _fileExtensions = ".jt,.cojt,.xml";
// --- Service-Optionen ---
[ObservableProperty] private bool _autoStartService = true;
[ObservableProperty] private bool _autoStartTrayApp = true;
[ObservableProperty] private bool _startAfterInstall = true;
[ObservableProperty] private bool _createDesktopShortcut = true;
[ObservableProperty] private bool _createStartMenuEntry = true;
// --- Backup-Einstellungen ---
[ObservableProperty] private bool _backupEnabled = true;
[ObservableProperty] private bool _backupUseCustomPath = false;
[ObservableProperty] private string _backupCustomPath = string.Empty;
[ObservableProperty] private int _maxBackupsPerFile = 0;
}

View File

@@ -0,0 +1,135 @@
using System.Collections.ObjectModel;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using EngineeringSync.Setup.Services;
using EngineeringSync.Setup.Views.Pages;
namespace EngineeringSync.Setup.ViewModels;
public partial class WizardViewModel : ObservableObject
{
public WizardState State { get; } = new();
[ObservableProperty] private WizardPageBase _currentPage = null!;
[ObservableProperty] private int _currentStepIndex;
[ObservableProperty] private bool _canGoBack;
[ObservableProperty] private bool _canGoNext = true;
[ObservableProperty] private bool _isLastStep;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowNextButton))]
private bool _isInstalling;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowNextButton))]
private bool _isCompleted;
public bool ShowNextButton => !IsCompleted && !IsInstalling;
public ObservableCollection<WizardStep> Steps { get; } =
[
new("Willkommen", "\uE80F"), // Home icon
new("Installation", "\uE7B7"), // Folder icon
new("Erstes Projekt","\uE8B7"), // Link icon
new("Backup", "\uE72E"), // Save icon (NEU)
new("Optionen", "\uE713"), // Settings icon
new("Zusammenfassung","\uE8A9"), // List icon
new("Installation", "\uE896"), // Download icon
new("Fertig", "\uE930"), // Completed icon
];
private readonly List<Func<WizardPageBase>> _pageFactories;
private readonly InstallerService _installer;
public WizardViewModel()
{
_installer = new InstallerService(State);
_pageFactories =
[
() => new WelcomePage(this),
() => new InstallPathPage(this),
() => new FirstProjectPage(this),
() => new BackupOptionsPage(this), // NEU Index 3
() => new ServiceOptionsPage(this),
() => new SummaryPage(this),
() => new InstallingPage(this, _installer),
() => new CompletionPage(this),
];
NavigateTo(0);
}
public void NavigateTo(int index)
{
CurrentStepIndex = index;
CurrentPage = _pageFactories[index]();
Steps[index].IsActive = true;
for (int i = 0; i < Steps.Count; i++)
{
Steps[i].IsCompleted = i < index;
Steps[i].IsActive = i == index;
}
CanGoBack = index > 0 && index < Steps.Count - 1 && !IsInstalling;
IsLastStep = index == Steps.Count - 2; // "Zusammenfassung" ist letzter normaler Schritt
// IsInstalling wird NICHT hier gesetzt wird von InstallingPage kontrolliert
IsCompleted = index == Steps.Count - 1;
CanGoNext = !IsInstalling && !IsCompleted;
}
public void SetInstallingState(bool installing)
{
IsInstalling = installing;
// CanGoNext wird durch die Bedingung !IsInstalling && !IsCompleted neu evaluiert
CanGoNext = !IsInstalling && !IsCompleted;
// GoBackCommand und CancelCommand CanExecute Bedingungen neu evaluieren
GoBackCommand.NotifyCanExecuteChanged();
CancelCommand.NotifyCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(CanExecuteGoBack))]
private void GoBack() => NavigateTo(CurrentStepIndex - 1);
private bool CanExecuteGoBack => CanGoBack && !IsInstalling;
[RelayCommand]
private void GoNext()
{
if (!CurrentPage.Validate()) return;
if (CurrentStepIndex < _pageFactories.Count - 1)
NavigateTo(CurrentStepIndex + 1);
}
[RelayCommand(CanExecute = nameof(CanExecuteCancel))]
private void Cancel()
{
if (IsCompleted) return;
var result = MessageBox.Show(
"Setup wirklich abbrechen?",
"EngineeringSync Setup",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
Application.Current.Shutdown();
}
private bool CanExecuteCancel => !IsInstalling && !IsCompleted;
[RelayCommand]
private void Finish()
{
if (State.StartAfterInstall)
_installer.LaunchTrayApp();
Application.Current.Shutdown();
}
}
public partial class WizardStep(string title, string icon) : ObservableObject
{
public string Title { get; } = title;
public string Icon { get; } = icon;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private bool _isCompleted;
}

View File

@@ -0,0 +1,86 @@
<local:WizardPageBase x:Class="EngineeringSync.Setup.Views.Pages.BackupOptionsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:EngineeringSync.Setup.Views.Pages"
Background="White">
<Grid Margin="40,32,40,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Backup-Einstellungen"
Style="{StaticResource PageTitleStyle}"/>
<TextBlock Grid.Row="1" Style="{StaticResource PageSubtitleStyle}"
Text="Legen Sie fest, ob und wie Sicherungskopien vor dem Überschreiben von Simulationsdateien erstellt werden."/>
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Master-Toggle -->
<local:OptionCard Icon="&#xE72E;" Title="Backups aktivieren"
Description="Vor jeder Dateiüberschreibung wird automatisch eine .bak-Sicherungskopie erstellt"
IsChecked="{Binding BackupEnabled, Mode=TwoWay}"/>
<!-- Optionen (nur wenn Backup aktiviert) -->
<StackPanel Visibility="{Binding BackupEnabled,
Converter={StaticResource BoolToVisConverter}}">
<!-- Speicherort -->
<TextBlock Text="SPEICHERORT" Style="{StaticResource FieldLabelStyle}"
Margin="0,16,0,8"/>
<!-- Mode=OneWay: gegenseitiger Ausschluss läuft über GroupName + TwoWay am zweiten Radio -->
<RadioButton GroupName="BackupLocation"
Content="Gleicher Ordner wie die Simulationsdatei"
IsChecked="{Binding BackupUseCustomPath,
Converter={StaticResource BoolToInvVisConverter},
Mode=OneWay}"
FontFamily="Segoe UI" FontSize="13" Margin="0,0,0,8"/>
<RadioButton GroupName="BackupLocation"
Content="Eigener Backup-Ordner"
IsChecked="{Binding BackupUseCustomPath, Mode=TwoWay}"
FontFamily="Segoe UI" FontSize="13" Margin="0,0,0,8"/>
<!-- Pfad-Eingabe (nur bei eigenem Ordner) -->
<DockPanel Margin="0,0,0,16"
Visibility="{Binding BackupUseCustomPath,
Converter={StaticResource BoolToVisConverter}}">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}"
Margin="6,0,0,0" Click="BrowseBackupPath_Click"
ToolTip="Backup-Verzeichnis wählen">
<TextBlock Text="&#xED25;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#0078D4"/>
</Button>
<TextBox Style="{StaticResource ModernTextBoxStyle}"
Text="{Binding BackupCustomPath, UpdateSourceTrigger=PropertyChanged}"
Height="36"/>
</DockPanel>
<!-- Aufbewahrung -->
<TextBlock Text="AUFBEWAHRUNG" Style="{StaticResource FieldLabelStyle}"
Margin="0,0,0,8"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Maximal" FontFamily="Segoe UI" FontSize="13"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="MaxBackupsBox"
Width="60" Height="32" Padding="6,0"
FontFamily="Segoe UI" FontSize="13"
Text="{Binding MaxBackupsPerFile, UpdateSourceTrigger=PropertyChanged}"
VerticalContentAlignment="Center"/>
<TextBlock Text="Backups pro Datei" FontFamily="Segoe UI" FontSize="13"
VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<TextBlock Text="(0 = unbegrenzt, alle Backups behalten)"
FontFamily="Segoe UI" FontSize="11" Foreground="#5F5F5F"
Margin="0,2,0,0"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</local:WizardPageBase>

View File

@@ -0,0 +1,39 @@
using System.Windows;
using EngineeringSync.Setup.ViewModels;
using Microsoft.Win32;
namespace EngineeringSync.Setup.Views.Pages;
public partial class BackupOptionsPage : WizardPageBase
{
public BackupOptionsPage(WizardViewModel wizard) : base(wizard)
{
InitializeComponent();
}
private void BrowseBackupPath_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog
{
Title = "Backup-Verzeichnis wählen",
InitialDirectory = string.IsNullOrEmpty(Wizard.State.BackupCustomPath)
? null
: Wizard.State.BackupCustomPath
};
if (dlg.ShowDialog() == true)
Wizard.State.BackupCustomPath = dlg.FolderName;
}
public override bool Validate()
{
if (Wizard.State.BackupEnabled &&
Wizard.State.BackupUseCustomPath &&
string.IsNullOrWhiteSpace(Wizard.State.BackupCustomPath))
{
MessageBox.Show("Bitte wählen Sie einen Backup-Ordner oder deaktivieren Sie die Option 'Eigener Backup-Ordner'.",
"Validierung", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
return true;
}
}

View File

@@ -0,0 +1,62 @@
<local:WizardPageBase x:Class="EngineeringSync.Setup.Views.Pages.CompletionPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:EngineeringSync.Setup.Views.Pages"
Background="White">
<Grid>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
MaxWidth="400" Margin="40">
<!-- Großes Erfolgs-Icon -->
<Border Width="80" Height="80" CornerRadius="40"
HorizontalAlignment="Center" Margin="0,0,0,24">
<Border.Background>
<RadialGradientBrush>
<GradientStop Color="#E8F5E9" Offset="0"/>
<GradientStop Color="#C8E6C9" Offset="1"/>
</RadialGradientBrush>
</Border.Background>
<TextBlock Text="&#xE930;" FontFamily="Segoe MDL2 Assets" FontSize="40"
Foreground="#107C10" HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="Installation erfolgreich!"
FontFamily="Segoe UI" FontSize="22" FontWeight="Light"
Foreground="#1A1A1A" HorizontalAlignment="Center"
Margin="0,0,0,12"/>
<TextBlock FontFamily="Segoe UI" FontSize="13" Foreground="#5F5F5F"
TextAlignment="Center" TextWrapping="Wrap" LineHeight="22"
Margin="0,0,0,32">
<Run Text="EngineeringSync wurde erfolgreich installiert und konfiguriert."/>
<LineBreak/>
<Run Text="Der Windows-Dienst läuft im Hintergrund und überwacht Ihren Engineering-Ordner."/>
</TextBlock>
<!-- Status-Chips -->
<WrapPanel HorizontalAlignment="Center" Margin="0,0,0,32">
<Border Background="#E8F5E9" CornerRadius="12" Padding="12,6" Margin="4">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE8FE;" FontFamily="Segoe MDL2 Assets"
FontSize="11" Foreground="#107C10" Margin="0,0,6,0"/>
<TextBlock Text="Dienst aktiv" FontFamily="Segoe UI"
FontSize="12" Foreground="#107C10"/>
</StackPanel>
</Border>
<Border Background="#E3F2FD" CornerRadius="12" Padding="12,6" Margin="4">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE756;" FontFamily="Segoe MDL2 Assets"
FontSize="11" Foreground="#0078D4" Margin="0,0,6,0"/>
<TextBlock Text="Tray-App startet" FontFamily="Segoe UI"
FontSize="12" Foreground="#0078D4"/>
</StackPanel>
</Border>
</WrapPanel>
<TextBlock FontFamily="Segoe UI" FontSize="12" Foreground="#888888"
TextAlignment="Center" TextWrapping="Wrap"
Text="Klicken Sie auf &quot;Schließen&quot; um den Assistenten zu beenden."/>
</StackPanel>
</Grid>
</local:WizardPageBase>

View File

@@ -0,0 +1,11 @@
using EngineeringSync.Setup.ViewModels;
namespace EngineeringSync.Setup.Views.Pages;
public partial class CompletionPage : WizardPageBase
{
public CompletionPage(WizardViewModel wizard) : base(wizard)
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,17 @@
<UserControl x:Class="EngineeringSync.Setup.Views.Pages.FeatureRow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="0,0,0,12">
<StackPanel Orientation="Horizontal">
<Border Width="36" Height="36" CornerRadius="8" Background="#F0F7FF" Margin="0,0,12,0">
<TextBlock x:Name="IconText" FontFamily="Segoe MDL2 Assets" FontSize="16"
Foreground="#0078D4" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock x:Name="TitleText" FontFamily="Segoe UI" FontSize="13"
FontWeight="SemiBold" Foreground="#1A1A1A"/>
<TextBlock x:Name="DescText" FontFamily="Segoe UI" FontSize="11"
Foreground="#5F5F5F" TextWrapping="Wrap" MaxWidth="420"/>
</StackPanel>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,25 @@
using System.Windows;
using System.Windows.Controls;
namespace EngineeringSync.Setup.Views.Pages;
public partial class FeatureRow : UserControl
{
public static readonly DependencyProperty IconProperty =
DependencyProperty.Register(nameof(Icon), typeof(string), typeof(FeatureRow),
new PropertyMetadata(string.Empty, (d, e) => ((FeatureRow)d).IconText.Text = (string)e.NewValue));
public static readonly DependencyProperty TitleProperty =
DependencyProperty.Register(nameof(Title), typeof(string), typeof(FeatureRow),
new PropertyMetadata(string.Empty, (d, e) => ((FeatureRow)d).TitleText.Text = (string)e.NewValue));
public static readonly DependencyProperty DescriptionProperty =
DependencyProperty.Register(nameof(Description), typeof(string), typeof(FeatureRow),
new PropertyMetadata(string.Empty, (d, e) => ((FeatureRow)d).DescText.Text = (string)e.NewValue));
public string Icon { get => (string)GetValue(IconProperty); set => SetValue(IconProperty, value); }
public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
public string Description { get => (string)GetValue(DescriptionProperty); set => SetValue(DescriptionProperty, value); }
public FeatureRow() => InitializeComponent();
}

View File

@@ -0,0 +1,138 @@
<local:WizardPageBase x:Class="EngineeringSync.Setup.Views.Pages.FirstProjectPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:EngineeringSync.Setup.Views.Pages"
Background="White">
<Grid Margin="40,32,40,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Erstes Projekt konfigurieren"
Style="{StaticResource PageTitleStyle}"/>
<TextBlock Grid.Row="1" Style="{StaticResource PageSubtitleStyle}"
Text="Definieren Sie Ihr erstes überwachtes Projekt. Sie können später über die Tray-App beliebig viele weitere Projekte hinzufügen."/>
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Projektname -->
<StackPanel Margin="0,0,0,16">
<TextBlock Text="PROJEKTNAME" Style="{StaticResource FieldLabelStyle}"/>
<TextBox Style="{StaticResource ModernTextBoxStyle}"
Text="{Binding ProjectName, UpdateSourceTrigger=PropertyChanged}"
Height="36"/>
</StackPanel>
<!-- Engineering-Pfad -->
<StackPanel Margin="0,0,0,16">
<TextBlock Style="{StaticResource FieldLabelStyle}">
<Run Text="ENGINEERING-QUELLPFAD "/>
<Run Text="(wird überwacht)" Foreground="#0078D4" FontWeight="Normal"/>
</TextBlock>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}"
Margin="6,0,0,0" Click="BrowseEngineering_Click"
ToolTip="Engineering-Verzeichnis wählen">
<TextBlock Text="&#xED25;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#0078D4"/>
</Button>
<TextBox Style="{StaticResource ModernTextBoxStyle}"
Text="{Binding EngineeringPath, UpdateSourceTrigger=PropertyChanged}"
Height="36"/>
</DockPanel>
<TextBlock Text="Änderungen in diesem Verzeichnis werden protokolliert und dem Simulations-Ingenieur gemeldet."
FontFamily="Segoe UI" FontSize="11" Foreground="#5F5F5F" Margin="0,4,0,0"/>
</StackPanel>
<!-- Simulations-Pfad -->
<StackPanel Margin="0,0,0,16">
<TextBlock Style="{StaticResource FieldLabelStyle}">
<Run Text="SIMULATIONS-ZIELPFAD "/>
<Run Text="(Sync-Ziel)" Foreground="#107C10" FontWeight="Normal"/>
</TextBlock>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}"
Margin="6,0,0,0" Click="BrowseSimulation_Click"
ToolTip="Simulations-Verzeichnis wählen">
<TextBlock Text="&#xED25;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#107C10"/>
</Button>
<TextBox Style="{StaticResource ModernTextBoxStyle}"
Text="{Binding SimulationPath, UpdateSourceTrigger=PropertyChanged}"
Height="36"/>
</DockPanel>
<TextBlock Text="Freigegebene Änderungen werden kontrolliert in dieses Verzeichnis kopiert (mit automatischem Backup)."
FontFamily="Segoe UI" FontSize="11" Foreground="#5F5F5F" Margin="0,4,0,0"/>
</StackPanel>
<!-- Dateiüberwachung -->
<StackPanel Margin="0,0,0,16">
<TextBlock Text="ÜBERWACHTE DATEIEN" Style="{StaticResource FieldLabelStyle}"/>
<!-- Option: Alle Dateien -->
<Border Background="#F0F8FF" CornerRadius="6" Padding="12,10" Margin="0,4,0,8">
<StackPanel Orientation="Horizontal">
<CheckBox x:Name="WatchAllCheck"
IsChecked="{Binding WatchAllFiles}"
VerticalAlignment="Center"
Margin="0,0,10,0"/>
<StackPanel>
<TextBlock Text="Alle Dateitypen überwachen"
FontFamily="Segoe UI" FontSize="13" FontWeight="SemiBold"
Foreground="#1B1B2F"/>
<TextBlock Text="Jede Dateiänderung im Ordner wird protokolliert (unabhängig von der Endung)."
FontFamily="Segoe UI" FontSize="11" Foreground="#5F5F5F"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Spezifische Endungen (nur wenn nicht "Alle Dateien") -->
<StackPanel Visibility="{Binding WatchAllFiles,
Converter={StaticResource BoolToInvVisConverter}}">
<TextBlock Text="DATEIENDUNGEN (komma-getrennt)"
Style="{StaticResource FieldLabelStyle}" Margin="0,0,0,6"/>
<!-- Quick-Select Buttons -->
<WrapPanel Margin="0,0,0,8">
<Button Content=".jt" Tag=".jt" Click="AddExt_Click"
Style="{StaticResource SecondaryButtonStyle}"
Padding="8,4" Margin="0,0,6,6" Height="26" FontSize="11"/>
<Button Content=".cojt" Tag=".cojt" Click="AddExt_Click"
Style="{StaticResource SecondaryButtonStyle}"
Padding="8,4" Margin="0,0,6,6" Height="26" FontSize="11"/>
<Button Content=".xml" Tag=".xml" Click="AddExt_Click"
Style="{StaticResource SecondaryButtonStyle}"
Padding="8,4" Margin="0,0,6,6" Height="26" FontSize="11"/>
<Button Content=".stp" Tag=".stp" Click="AddExt_Click"
Style="{StaticResource SecondaryButtonStyle}"
Padding="8,4" Margin="0,0,6,6" Height="26" FontSize="11"/>
<Button Content=".step" Tag=".step" Click="AddExt_Click"
Style="{StaticResource SecondaryButtonStyle}"
Padding="8,4" Margin="0,0,6,6" Height="26" FontSize="11"/>
<Button Content=".igs" Tag=".igs" Click="AddExt_Click"
Style="{StaticResource SecondaryButtonStyle}"
Padding="8,4" Margin="0,0,6,6" Height="26" FontSize="11"/>
<Button Content=".iges" Tag=".iges" Click="AddExt_Click"
Style="{StaticResource SecondaryButtonStyle}"
Padding="8,4" Margin="0,0,6,6" Height="26" FontSize="11"/>
<Button Content=".prt" Tag=".prt" Click="AddExt_Click"
Style="{StaticResource SecondaryButtonStyle}"
Padding="8,4" Margin="0,0,6,6" Height="26" FontSize="11"/>
</WrapPanel>
<TextBox x:Name="ExtensionsBox"
Style="{StaticResource ModernTextBoxStyle}"
Text="{Binding FileExtensions, UpdateSourceTrigger=PropertyChanged}"
Height="36"/>
<TextBlock Text="Klicke auf einen Typ um ihn hinzuzufügen, oder tippe direkt."
FontFamily="Segoe UI" FontSize="11" Foreground="#5F5F5F" Margin="0,4,0,0"/>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</local:WizardPageBase>

View File

@@ -0,0 +1,79 @@
using System.IO;
using System.Windows;
using System.Windows.Controls;
using EngineeringSync.Setup.ViewModels;
using Microsoft.Win32;
namespace EngineeringSync.Setup.Views.Pages;
public partial class FirstProjectPage : WizardPageBase
{
public FirstProjectPage(WizardViewModel wizard) : base(wizard)
{
InitializeComponent();
}
private void BrowseEngineering_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog
{
Title = "Engineering-Quellpfad wählen",
InitialDirectory = Wizard.State.EngineeringPath
};
if (dlg.ShowDialog() == true)
Wizard.State.EngineeringPath = dlg.FolderName;
}
private void BrowseSimulation_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog
{
Title = "Simulations-Zielpfad wählen",
InitialDirectory = Wizard.State.SimulationPath
};
if (dlg.ShowDialog() == true)
Wizard.State.SimulationPath = dlg.FolderName;
}
private void AddExt_Click(object sender, RoutedEventArgs e)
{
var ext = (sender as Button)?.Tag?.ToString();
if (string.IsNullOrEmpty(ext)) return;
var current = Wizard.State.FileExtensions?.Trim() ?? string.Empty;
var parts = current.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (!parts.Contains(ext, StringComparer.OrdinalIgnoreCase))
Wizard.State.FileExtensions = current.Length > 0 ? current + "," + ext : ext;
}
public override bool Validate()
{
if (string.IsNullOrWhiteSpace(Wizard.State.ProjectName))
{
MessageBox.Show("Bitte geben Sie einen Projektnamen an.",
"Validierung", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
if (string.IsNullOrWhiteSpace(Wizard.State.EngineeringPath) ||
!Directory.Exists(Wizard.State.EngineeringPath))
{
MessageBox.Show("Der Engineering-Pfad existiert nicht oder ist nicht angegeben.",
"Validierung", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
if (string.IsNullOrWhiteSpace(Wizard.State.SimulationPath) ||
!Directory.Exists(Wizard.State.SimulationPath))
{
MessageBox.Show("Der Simulations-Pfad existiert nicht oder ist nicht angegeben.",
"Validierung", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
if (!Wizard.State.WatchAllFiles && string.IsNullOrWhiteSpace(Wizard.State.FileExtensions))
{
MessageBox.Show("Bitte geben Sie mindestens eine Dateiendung an oder wählen Sie 'Alle Dateitypen überwachen'.",
"Validierung", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
return true;
}
}

View File

@@ -0,0 +1,10 @@
<UserControl x:Class="EngineeringSync.Setup.Views.Pages.InstallItem"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="0,2">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE73E;" FontFamily="Segoe MDL2 Assets" FontSize="10"
Foreground="#107C10" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock x:Name="ItemText" FontFamily="Consolas" FontSize="12" Foreground="#1A1A1A"/>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,14 @@
using System.Windows;
using System.Windows.Controls;
namespace EngineeringSync.Setup.Views.Pages;
public partial class InstallItem : UserControl
{
public static readonly DependencyProperty TextProperty =
DependencyProperty.Register(nameof(Text), typeof(string), typeof(InstallItem),
new PropertyMetadata(string.Empty, (d, e) => ((InstallItem)d).ItemText.Text = (string)e.NewValue));
public string Text { get => (string)GetValue(TextProperty); set => SetValue(TextProperty, value); }
public InstallItem() => InitializeComponent();
}

View File

@@ -0,0 +1,61 @@
<local:WizardPageBase x:Class="EngineeringSync.Setup.Views.Pages.InstallPathPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:EngineeringSync.Setup.Views.Pages"
Background="White">
<Grid Margin="40,32,40,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Installationsverzeichnis"
Style="{StaticResource PageTitleStyle}"/>
<TextBlock Grid.Row="1" Style="{StaticResource PageSubtitleStyle}"
Text="Wählen Sie das Verzeichnis, in dem EngineeringSync installiert werden soll. Der Windows-Dienst und die Tray-App werden dort abgelegt."/>
<!-- Pfad-Auswahl -->
<StackPanel Grid.Row="2" Margin="0,0,0,20">
<TextBlock Text="INSTALLATIONSPFAD" Style="{StaticResource FieldLabelStyle}"/>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}"
Margin="6,0,0,0" ToolTip="Verzeichnis auswählen"
Click="Browse_Click">
<TextBlock Text="&#xED25;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#0078D4"/>
</Button>
<TextBox x:Name="PathBox"
Style="{StaticResource ModernTextBoxStyle}"
Text="{Binding InstallPath, UpdateSourceTrigger=PropertyChanged}"
Height="36"/>
</DockPanel>
<!-- Speicherplatz-Hinweis -->
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="&#xE8C8;" FontFamily="Segoe MDL2 Assets"
FontSize="11" Foreground="#5F5F5F" Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock Text="Benötigter Speicherplatz: ca. 45 MB"
FontFamily="Segoe UI" FontSize="11" Foreground="#5F5F5F"/>
</StackPanel>
</StackPanel>
<!-- Info-Karte -->
<Border Grid.Row="3" Style="{StaticResource InfoCardStyle}" VerticalAlignment="Top">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="&#xE946;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#0078D4" Margin="0,0,8,0"/>
<TextBlock Text="Folgende Komponenten werden installiert:"
FontFamily="Segoe UI" FontSize="12" FontWeight="SemiBold"
Foreground="#1A1A1A"/>
</StackPanel>
<local:InstallItem Text="EngineeringSync.Service.exe (Windows-Dienst)"/>
<local:InstallItem Text="EngineeringSync.TrayApp.exe (Tray-Anwendung)"/>
<local:InstallItem Text="engineeringsync.db (Datenbank)"/>
<local:InstallItem Text="appsettings.json (Konfiguration)"/>
</StackPanel>
</Border>
</Grid>
</local:WizardPageBase>

View File

@@ -0,0 +1,35 @@
using System.Windows;
using EngineeringSync.Setup.ViewModels;
using Microsoft.Win32;
namespace EngineeringSync.Setup.Views.Pages;
public partial class InstallPathPage : WizardPageBase
{
public InstallPathPage(WizardViewModel wizard) : base(wizard)
{
InitializeComponent();
}
private void Browse_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog
{
Title = "Installationsverzeichnis auswählen",
InitialDirectory = Wizard.State.InstallPath
};
if (dlg.ShowDialog() == true)
Wizard.State.InstallPath = dlg.FolderName;
}
public override bool Validate()
{
if (string.IsNullOrWhiteSpace(Wizard.State.InstallPath))
{
MessageBox.Show("Bitte wählen Sie ein Installationsverzeichnis.",
"Validierung", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
return true;
}
}

View File

@@ -0,0 +1,65 @@
<local:WizardPageBase x:Class="EngineeringSync.Setup.Views.Pages.InstallingPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:EngineeringSync.Setup.Views.Pages"
Background="White"
Loaded="Page_Loaded">
<Grid Margin="40,32,40,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Installation läuft..."
Style="{StaticResource PageTitleStyle}"/>
<TextBlock Grid.Row="1" x:Name="SubtitleText"
Style="{StaticResource PageSubtitleStyle}"
Text="Bitte warten Sie, während EngineeringSync installiert wird."/>
<!-- Fortschrittsbereich -->
<StackPanel Grid.Row="2" VerticalAlignment="Center">
<!-- Aktueller Schritt -->
<TextBlock x:Name="StepText"
FontFamily="Segoe UI" FontSize="13" FontWeight="SemiBold"
Foreground="#1A1A1A" Margin="0,0,0,8" Text="Starte Installation..."/>
<!-- Fortschrittsbalken -->
<ProgressBar x:Name="ProgressBar"
Style="{StaticResource ModernProgressBarStyle}"
Minimum="0" Maximum="100" Value="0"
Margin="0,0,0,4"/>
<Grid Margin="0,0,0,24">
<TextBlock x:Name="ProgressText" FontFamily="Segoe UI" FontSize="11"
Foreground="#5F5F5F" HorizontalAlignment="Left" Text="0%"/>
<TextBlock x:Name="TimeText" FontFamily="Segoe UI" FontSize="11"
Foreground="#5F5F5F" HorizontalAlignment="Right" Text=""/>
</Grid>
<!-- Live-Log -->
<Border Background="#1E1E1E" CornerRadius="8" Padding="16" Height="180">
<ScrollViewer x:Name="LogScrollViewer" VerticalScrollBarVisibility="Auto">
<TextBlock x:Name="LogText"
FontFamily="Cascadia Code, Consolas, Courier New"
FontSize="11" Foreground="#CCCCCC"
TextWrapping="Wrap" LineHeight="18"/>
</ScrollViewer>
</Border>
</StackPanel>
<!-- Erfolgs/Fehler-Banner -->
<Border Grid.Row="3" x:Name="ResultBanner"
CornerRadius="8" Padding="14,10" Margin="0,16,0,0"
Visibility="Collapsed">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="ResultIcon" FontFamily="Segoe MDL2 Assets"
FontSize="16" Margin="0,0,10,0" VerticalAlignment="Center"/>
<TextBlock x:Name="ResultText" FontFamily="Segoe UI" FontSize="12"
VerticalAlignment="Center" TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</local:WizardPageBase>

View File

@@ -0,0 +1,102 @@
using System.Windows;
using System.Windows.Media;
using EngineeringSync.Setup.Services;
using EngineeringSync.Setup.ViewModels;
namespace EngineeringSync.Setup.Views.Pages;
public partial class InstallingPage : WizardPageBase
{
private readonly InstallerService _installer;
private bool _started;
public InstallingPage(WizardViewModel wizard, InstallerService installer) : base(wizard)
{
_installer = installer;
InitializeComponent();
}
private async void Page_Loaded(object sender, RoutedEventArgs e)
{
// Nur einmal starten (Page kann bei Navigation neu erzeugt werden)
if (_started) return;
_started = true;
_installer.Progress += OnProgress;
_installer.LogMessage += OnLogMessage;
try
{
// Button während Installation deaktivieren
Wizard.SetInstallingState(true);
await _installer.InstallAsync();
ShowSuccess();
// Buttons wieder aktivieren vor Navigation
Wizard.SetInstallingState(false);
Wizard.NavigateTo(7); // → CompletionPage
}
catch (Exception ex)
{
ShowError(ex.Message);
// Buttons wieder aktivieren im Fehlerfall
Wizard.SetInstallingState(false);
}
finally
{
_installer.Progress -= OnProgress;
_installer.LogMessage -= OnLogMessage;
}
}
private void OnProgress(int percent, string step)
{
Dispatcher.Invoke(() =>
{
ProgressBar.Value = percent;
ProgressText.Text = $"{percent}%";
StepText.Text = step;
});
}
private void OnLogMessage(string message)
{
Dispatcher.Invoke(() =>
{
var ts = DateTime.Now.ToString("HH:mm:ss");
LogText.Text += $"[{ts}] {message}\n";
LogScrollViewer.ScrollToEnd();
});
}
private void ShowSuccess()
{
Dispatcher.Invoke(() =>
{
ResultBanner.Background = new SolidColorBrush(Color.FromRgb(232, 245, 233));
ResultIcon.Text = "\uE73E";
ResultIcon.Foreground = new SolidColorBrush(Color.FromRgb(16, 124, 16));
ResultText.Text = "Installation erfolgreich abgeschlossen.";
ResultText.Foreground = new SolidColorBrush(Color.FromRgb(16, 124, 16));
ResultBanner.Visibility = Visibility.Visible;
SubtitleText.Text = "Alle Komponenten wurden erfolgreich installiert.";
ProgressBar.Value = 100;
ProgressText.Text = "100%";
});
}
private void ShowError(string message)
{
Dispatcher.Invoke(() =>
{
ResultBanner.Background = new SolidColorBrush(Color.FromRgb(255, 235, 238));
ResultIcon.Text = "\uE783";
ResultIcon.Foreground = new SolidColorBrush(Color.FromRgb(196, 43, 28));
ResultText.Text = $"Fehler: {message}";
ResultText.Foreground = new SolidColorBrush(Color.FromRgb(196, 43, 28));
ResultBanner.Visibility = Visibility.Visible;
SubtitleText.Text = "Die Installation konnte nicht abgeschlossen werden.";
});
}
}

View File

@@ -0,0 +1,32 @@
<UserControl x:Class="EngineeringSync.Setup.Views.Pages.OptionCard"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="0,0,0,8">
<Border x:Name="CardBorder" Background="White" BorderThickness="1"
BorderBrush="#E0E0E0" CornerRadius="8" Padding="16,12"
Cursor="Hand" MouseLeftButtonDown="Card_Click">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Width="32" Height="32" CornerRadius="8" Background="#F0F7FF">
<TextBlock x:Name="IconText" FontFamily="Segoe MDL2 Assets" FontSize="15"
Foreground="#0078D4" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="12,0">
<TextBlock x:Name="TitleText" FontFamily="Segoe UI" FontSize="13"
FontWeight="SemiBold" Foreground="#1A1A1A"/>
<TextBlock x:Name="DescText" FontFamily="Segoe UI" FontSize="11"
Foreground="#5F5F5F" TextWrapping="Wrap"/>
</StackPanel>
<CheckBox x:Name="ToggleBox" Grid.Column="2"
Style="{StaticResource ModernCheckBoxStyle}"
VerticalAlignment="Center" IsHitTestVisible="False"/>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,47 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace EngineeringSync.Setup.Views.Pages;
public partial class OptionCard : UserControl
{
public static readonly DependencyProperty IconProperty =
DependencyProperty.Register(nameof(Icon), typeof(string), typeof(OptionCard),
new PropertyMetadata(string.Empty, (d, e) => ((OptionCard)d).IconText.Text = (string)e.NewValue));
public static readonly DependencyProperty TitleProperty =
DependencyProperty.Register(nameof(Title), typeof(string), typeof(OptionCard),
new PropertyMetadata(string.Empty, (d, e) => ((OptionCard)d).TitleText.Text = (string)e.NewValue));
public static readonly DependencyProperty DescriptionProperty =
DependencyProperty.Register(nameof(Description), typeof(string), typeof(OptionCard),
new PropertyMetadata(string.Empty, (d, e) => ((OptionCard)d).DescText.Text = (string)e.NewValue));
public static readonly DependencyProperty IsCheckedProperty =
DependencyProperty.Register(nameof(IsChecked), typeof(bool), typeof(OptionCard),
new FrameworkPropertyMetadata(true,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
(d, e) => ((OptionCard)d).UpdateVisual((bool)e.NewValue)));
public string Icon { get => (string)GetValue(IconProperty); set => SetValue(IconProperty, value); }
public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
public string Description { get => (string)GetValue(DescriptionProperty); set => SetValue(DescriptionProperty, value); }
public bool IsChecked { get => (bool)GetValue(IsCheckedProperty); set => SetValue(IsCheckedProperty, value); }
public OptionCard() => InitializeComponent();
private void Card_Click(object sender, RoutedEventArgs e) => IsChecked = !IsChecked;
private void UpdateVisual(bool isChecked)
{
if (ToggleBox is null) return;
ToggleBox.IsChecked = isChecked;
CardBorder.BorderBrush = isChecked
? new SolidColorBrush(Color.FromRgb(0, 120, 212))
: new SolidColorBrush(Color.FromRgb(224, 224, 224));
CardBorder.Background = isChecked
? new SolidColorBrush(Color.FromArgb(15, 0, 120, 212))
: new SolidColorBrush(Colors.White);
}
}

View File

@@ -0,0 +1,51 @@
<local:WizardPageBase x:Class="EngineeringSync.Setup.Views.Pages.ServiceOptionsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:EngineeringSync.Setup.Views.Pages"
Background="White">
<Grid Margin="40,32,40,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Start- und Verknüpfungs-Optionen"
Style="{StaticResource PageTitleStyle}"/>
<TextBlock Grid.Row="1" Style="{StaticResource PageSubtitleStyle}"
Text="Legen Sie fest, wie EngineeringSync beim Start von Windows und nach der Installation verhalten soll."/>
<StackPanel Grid.Row="2">
<!-- Autostart-Gruppe -->
<TextBlock Text="AUTOSTART" Style="{StaticResource FieldLabelStyle}" Margin="0,0,0,10"/>
<local:OptionCard Icon="&#xE8FE;" Title="Windows-Dienst automatisch starten"
Description="Der EngineeringSync-Dienst startet automatisch mit Windows (empfohlen)"
IsChecked="{Binding AutoStartService, Mode=TwoWay}"/>
<local:OptionCard Icon="&#xE756;" Title="Tray-App bei Anmeldung starten"
Description="Die Benachrichtigungs-App erscheint automatisch im Systemtray nach dem Login"
IsChecked="{Binding AutoStartTrayApp, Mode=TwoWay}"/>
<!-- Verknüpfungen -->
<TextBlock Text="VERKNÜPFUNGEN" Style="{StaticResource FieldLabelStyle}" Margin="0,20,0,10"/>
<local:OptionCard Icon="&#xE7C5;" Title="Desktop-Verknüpfung erstellen"
Description="Erstellt eine Verknüpfung zur Tray-App auf dem Desktop"
IsChecked="{Binding CreateDesktopShortcut, Mode=TwoWay}"/>
<local:OptionCard Icon="&#xE8A9;" Title="Startmenü-Eintrag erstellen"
Description="Fügt EngineeringSync dem Windows-Startmenü hinzu"
IsChecked="{Binding CreateStartMenuEntry, Mode=TwoWay}"/>
<!-- Nach Installation -->
<TextBlock Text="NACH INSTALLATION" Style="{StaticResource FieldLabelStyle}" Margin="0,20,0,10"/>
<local:OptionCard Icon="&#xE768;" Title="Tray-App nach Installation starten"
Description="Startet die Tray-App direkt nach Abschluss der Installation"
IsChecked="{Binding StartAfterInstall, Mode=TwoWay}"/>
</StackPanel>
</Grid>
</local:WizardPageBase>

View File

@@ -0,0 +1,11 @@
using EngineeringSync.Setup.ViewModels;
namespace EngineeringSync.Setup.Views.Pages;
public partial class ServiceOptionsPage : WizardPageBase
{
public ServiceOptionsPage(WizardViewModel wizard) : base(wizard)
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,18 @@
<UserControl x:Class="EngineeringSync.Setup.Views.Pages.SummaryBoolRow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="0,2">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="LabelText" Grid.Column="0"
FontFamily="Segoe UI" FontSize="12" Foreground="#5F5F5F"/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<TextBlock x:Name="CheckIcon" FontFamily="Segoe MDL2 Assets" FontSize="11"
Margin="0,0,6,0" VerticalAlignment="Center"/>
<TextBlock x:Name="ValueText" FontFamily="Segoe UI" FontSize="12"/>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,40 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace EngineeringSync.Setup.Views.Pages;
public partial class SummaryBoolRow : UserControl
{
public static readonly DependencyProperty LabelProperty =
DependencyProperty.Register(nameof(Label), typeof(string), typeof(SummaryBoolRow),
new PropertyMetadata(string.Empty, (d, e) => ((SummaryBoolRow)d).LabelText.Text = (string)e.NewValue));
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(bool), typeof(SummaryBoolRow),
new PropertyMetadata(false, (d, e) => ((SummaryBoolRow)d).UpdateVisual((bool)e.NewValue)));
public string Label { get => (string)GetValue(LabelProperty); set => SetValue(LabelProperty, value); }
public bool Value { get => (bool)GetValue(ValueProperty); set => SetValue(ValueProperty, value); }
public SummaryBoolRow() => InitializeComponent();
private void UpdateVisual(bool isOn)
{
if (CheckIcon is null) return;
if (isOn)
{
CheckIcon.Text = "\uE73E";
CheckIcon.Foreground = new SolidColorBrush(Color.FromRgb(16, 124, 16));
ValueText.Text = "Ja";
ValueText.Foreground = new SolidColorBrush(Color.FromRgb(16, 124, 16));
}
else
{
CheckIcon.Text = "\uE711";
CheckIcon.Foreground = new SolidColorBrush(Color.FromRgb(160, 160, 160));
ValueText.Text = "Nein";
ValueText.Foreground = new SolidColorBrush(Color.FromRgb(160, 160, 160));
}
}
}

View File

@@ -0,0 +1,92 @@
<local:WizardPageBase x:Class="EngineeringSync.Setup.Views.Pages.SummaryPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:EngineeringSync.Setup.Views.Pages"
Background="White">
<Grid Margin="40,32,40,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Zusammenfassung" Style="{StaticResource PageTitleStyle}"/>
<TextBlock Grid.Row="1" Style="{StaticResource PageSubtitleStyle}"
Text="Überprüfen Sie Ihre Einstellungen. Klicken Sie auf &quot;Jetzt installieren&quot; um die Installation zu starten."/>
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Installation -->
<Border Style="{StaticResource InfoCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="&#xE7B7;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#0078D4" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Installation" FontFamily="Segoe UI" FontSize="13"
FontWeight="SemiBold" Foreground="#1A1A1A" VerticalAlignment="Center"/>
</StackPanel>
<local:SummaryRow Label="Installationspfad" Value="{Binding InstallPath}"/>
</StackPanel>
</Border>
<!-- Erstes Projekt -->
<Border Style="{StaticResource InfoCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="&#xE8B7;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#0078D4" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Erstes Projekt" FontFamily="Segoe UI" FontSize="13"
FontWeight="SemiBold" Foreground="#1A1A1A" VerticalAlignment="Center"/>
</StackPanel>
<local:SummaryRow Label="Projektname" Value="{Binding ProjectName}"/>
<local:SummaryRow Label="Engineering-Pfad" Value="{Binding EngineeringPath}"/>
<local:SummaryRow Label="Simulations-Pfad" Value="{Binding SimulationPath}"/>
<local:SummaryRow Label="Dateiendungen" Value="{Binding FileExtensions}"/>
</StackPanel>
</Border>
<!-- Backup -->
<Border Style="{StaticResource InfoCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="&#xE72E;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#0078D4" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Backup" FontFamily="Segoe UI" FontSize="13"
FontWeight="SemiBold" Foreground="#1A1A1A" VerticalAlignment="Center"/>
</StackPanel>
<local:SummaryBoolRow Label="Backups aktiviert" Value="{Binding BackupEnabled}"/>
<local:SummaryRow Label="Backup-Ordner"
Value="{Binding BackupCustomPath}"
Visibility="{Binding BackupUseCustomPath,
Converter={StaticResource BoolToVisConverter}}"/>
<!-- Aufbewahrung immer anzeigen wenn Backup aktiv (0 = unbegrenzt ist ein valider Wert) -->
<local:SummaryRow Label="Max. Backups/Datei"
Value="{Binding MaxBackupsPerFile,
StringFormat='{}{0} (0=unbegrenzt)'}"
Visibility="{Binding BackupEnabled,
Converter={StaticResource BoolToVisConverter}}"/>
</StackPanel>
</Border>
<!-- Optionen -->
<Border Style="{StaticResource InfoCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="&#xE713;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#0078D4" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Optionen" FontFamily="Segoe UI" FontSize="13"
FontWeight="SemiBold" Foreground="#1A1A1A" VerticalAlignment="Center"/>
</StackPanel>
<local:SummaryBoolRow Label="Dienst automatisch starten" Value="{Binding AutoStartService}"/>
<local:SummaryBoolRow Label="Tray-App bei Anmeldung" Value="{Binding AutoStartTrayApp}"/>
<local:SummaryBoolRow Label="Desktop-Verknüpfung" Value="{Binding CreateDesktopShortcut}"/>
<local:SummaryBoolRow Label="Startmenü-Eintrag" Value="{Binding CreateStartMenuEntry}"/>
<local:SummaryBoolRow Label="Tray-App nach Installation" Value="{Binding StartAfterInstall}"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</local:WizardPageBase>

View File

@@ -0,0 +1,11 @@
using EngineeringSync.Setup.ViewModels;
namespace EngineeringSync.Setup.Views.Pages;
public partial class SummaryPage : WizardPageBase
{
public SummaryPage(WizardViewModel wizard) : base(wizard)
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,16 @@
<UserControl x:Class="EngineeringSync.Setup.Views.Pages.SummaryRow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Margin="0,2">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="140"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="LabelText" Grid.Column="0"
FontFamily="Segoe UI" FontSize="12" Foreground="#5F5F5F"/>
<TextBlock x:Name="ValueText" Grid.Column="1"
FontFamily="Segoe UI" FontSize="12" Foreground="#1A1A1A"
TextWrapping="Wrap"/>
</Grid>
</UserControl>

View File

@@ -0,0 +1,19 @@
using System.Windows;
using System.Windows.Controls;
namespace EngineeringSync.Setup.Views.Pages;
public partial class SummaryRow : UserControl
{
public static readonly DependencyProperty LabelProperty =
DependencyProperty.Register(nameof(Label), typeof(string), typeof(SummaryRow),
new PropertyMetadata(string.Empty, (d, e) => ((SummaryRow)d).LabelText.Text = (string)e.NewValue));
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register(nameof(Value), typeof(string), typeof(SummaryRow),
new PropertyMetadata(string.Empty, (d, e) => ((SummaryRow)d).ValueText.Text = (string)e.NewValue));
public string Label { get => (string)GetValue(LabelProperty); set => SetValue(LabelProperty, value); }
public string Value { get => (string)GetValue(ValueProperty); set => SetValue(ValueProperty, value); }
public SummaryRow() => InitializeComponent();
}

View File

@@ -0,0 +1,63 @@
<local:WizardPageBase x:Class="EngineeringSync.Setup.Views.Pages.WelcomePage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:EngineeringSync.Setup.Views.Pages"
Background="White">
<Grid Margin="40,32,40,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Großes Begrüßungs-Icon -->
<Border Grid.Row="0" Width="64" Height="64" CornerRadius="16"
HorizontalAlignment="Left" Margin="0,0,0,24">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#0078D4" Offset="0"/>
<GradientStop Color="#00B4FF" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<TextBlock Text="&#xE8B7;" FontFamily="Segoe MDL2 Assets" FontSize="32"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<!-- Überschrift -->
<StackPanel Grid.Row="1" Margin="0,0,0,20">
<TextBlock Text="Willkommen bei EngineeringSync"
Style="{StaticResource PageTitleStyle}"/>
<TextBlock Style="{StaticResource PageSubtitleStyle}"
Text="Dieser Assistent führt Sie durch die Installation und Erstkonfiguration von EngineeringSync dem intelligenten Bindeglied zwischen Ihrem Engineering- und Simulationsumfeld."/>
</StackPanel>
<!-- Feature-Liste -->
<StackPanel Grid.Row="2" VerticalAlignment="Top">
<TextBlock Text="Was wird installiert:"
FontFamily="Segoe UI" FontSize="12" FontWeight="SemiBold"
Foreground="#5F5F5F" Margin="0,0,0,12"/>
<local:FeatureRow Icon="&#xE8A0;" Title="EngineeringSync Service"
Description="Windows-Dienst der Ihr Engineering-Verzeichnis überwacht und Änderungen protokolliert"/>
<local:FeatureRow Icon="&#xE756;" Title="System Tray App"
Description="Benachrichtigt Sie sofort über neue Änderungen und ermöglicht kontrollierten Sync"/>
<local:FeatureRow Icon="&#xE713;" Title="Konfigurations-Wizard"
Description="Verwalten Sie Projekte und Sync-Pfade bequem per grafischer Oberfläche"/>
</StackPanel>
<!-- System-Check -->
<Border Grid.Row="3" Background="#F0F7FF" CornerRadius="8" Padding="14,10" Margin="0,16,0,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE946;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#0078D4" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock FontFamily="Segoe UI" FontSize="12" Foreground="#5F5F5F"
VerticalAlignment="Center" TextWrapping="Wrap">
<Run Text="Klicken Sie auf " FontWeight="Regular"/>
<Run Text="Weiter" FontWeight="SemiBold"/>
<Run Text=" um die Installation zu beginnen." FontWeight="Regular"/>
</TextBlock>
</StackPanel>
</Border>
</Grid>
</local:WizardPageBase>

View File

@@ -0,0 +1,11 @@
using EngineeringSync.Setup.ViewModels;
namespace EngineeringSync.Setup.Views.Pages;
public partial class WelcomePage : WizardPageBase
{
public WelcomePage(WizardViewModel wizard) : base(wizard)
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,25 @@
using System.Windows.Controls;
using EngineeringSync.Setup.ViewModels;
namespace EngineeringSync.Setup.Views.Pages;
/// <summary>
/// Basisklasse für alle Wizard-Seiten.
/// Stellt gemeinsame WizardViewModel-Referenz und Validierungsschnittstelle bereit.
/// </summary>
public abstract class WizardPageBase : UserControl
{
protected WizardViewModel Wizard { get; }
protected WizardPageBase(WizardViewModel wizard)
{
Wizard = wizard;
DataContext = wizard.State;
}
/// <summary>
/// Wird vor dem Vorwärtsnavigieren aufgerufen.
/// Gibt false zurück um die Navigation zu blockieren (z.B. Validierungsfehler).
/// </summary>
public virtual bool Validate() => true;
}

View File

@@ -0,0 +1,251 @@
<Window x:Class="EngineeringSync.Setup.Views.WizardWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:EngineeringSync.Setup.ViewModels"
Title="EngineeringSync Setup"
Width="860" Height="580"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent">
<Window.DataContext>
<vm:WizardViewModel/>
</Window.DataContext>
<!-- Äußerer Schatten-Container -->
<Border CornerRadius="10"
Background="White">
<Border.Effect>
<DropShadowEffect Color="#40000000" BlurRadius="30" ShadowDepth="0" Opacity="0.4"/>
</Border.Effect>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- ═══════════════════════════════
LINKE SIDEBAR
═══════════════════════════════ -->
<Border Grid.Column="0"
Background="{StaticResource SidebarGradient}"
CornerRadius="10,0,0,10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Logo-Bereich -->
<StackPanel Grid.Row="0" Margin="24,32,24,32">
<!-- App-Icon (Segoe MDL2 Gear-ähnliches Symbol) -->
<Border Width="52" Height="52"
CornerRadius="12"
HorizontalAlignment="Left"
Margin="0,0,0,16">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#0078D4" Offset="0"/>
<GradientStop Color="#00B4FF" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<TextBlock Text="&#xE8B7;"
FontFamily="Segoe MDL2 Assets"
FontSize="26"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="EngineeringSync"
FontFamily="Segoe UI"
FontSize="16"
FontWeight="SemiBold"
Foreground="White"/>
<TextBlock Text="Setup-Assistent"
FontFamily="Segoe UI"
FontSize="11"
Foreground="#7788AA"/>
</StackPanel>
<!-- Schritt-Indikatoren -->
<ItemsControl Grid.Row="1"
ItemsSource="{Binding Steps}"
Margin="20,0,20,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<ContentControl Style="{StaticResource StepItemStyle}"
Content="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Version -->
<TextBlock Grid.Row="2"
Text="Version 1.0.0"
FontFamily="Segoe UI"
FontSize="10"
Foreground="#445566"
Margin="24,0,24,20"
HorizontalAlignment="Left"/>
</Grid>
</Border>
<!-- ═══════════════════════════════
RECHTE INHALTSSEITE + NAVIGATION
═══════════════════════════════ -->
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- Custom Title Bar -->
<RowDefinition Height="*"/> <!-- Page Content -->
<RowDefinition Height="Auto"/> <!-- Navigation Bar -->
</Grid.RowDefinitions>
<!-- Custom Title Bar (Drag + Schließen) -->
<Border Grid.Row="0"
Height="40"
CornerRadius="0,10,0,0"
MouseLeftButtonDown="TitleBar_MouseDown">
<Grid>
<TextBlock Text="EngineeringSync Setup"
FontFamily="Segoe UI"
FontSize="12"
Foreground="#888888"
VerticalAlignment="Center"
Margin="16,0,0,0"/>
<Button Content="&#xE8BB;"
FontFamily="Segoe MDL2 Assets"
FontSize="12"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Width="40" Height="40"
Background="Transparent"
BorderThickness="0"
Foreground="#888888"
Cursor="Hand"
Click="CloseButton_Click"
ToolTip="Schließen">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bg"
Background="Transparent"
CornerRadius="0,10,0,0">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bg" Property="Background" Value="#FFE81123"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Button.Style>
</Button>
</Grid>
</Border>
<!-- ── Separator ── -->
<Border Grid.Row="0" Height="1" Background="{StaticResource BorderBrush}"
VerticalAlignment="Bottom"/>
<!-- Seiten-Content mit Animation -->
<ContentPresenter Grid.Row="1"
Content="{Binding CurrentPage}"
Margin="0"/>
<!-- ── Separator ── -->
<Border Grid.Row="2" Height="1" Background="{StaticResource BorderBrush}"
VerticalAlignment="Top"/>
<!-- Navigation Bar -->
<Border Grid.Row="2"
Background="#FAFAFA"
CornerRadius="0,0,10,0"
Padding="24,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Abbrechen (links) -->
<Button Grid.Column="0"
Content="Abbrechen"
HorizontalAlignment="Left"
Style="{StaticResource SecondaryButtonStyle}"
Command="{Binding CancelCommand}"
Visibility="{Binding IsCompleted,
Converter={StaticResource BoolToInvVisConverter}}"/>
<!-- Zurück -->
<Button Grid.Column="1"
Style="{StaticResource SecondaryButtonStyle}"
Margin="0,0,8,0"
Command="{Binding GoBackCommand}"
Visibility="{Binding CanGoBack,
Converter={StaticResource BoolToVisConverter}}">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE72B;"
FontFamily="Segoe MDL2 Assets"
Margin="0,0,6,0"/>
<TextBlock Text="Zurück"
FontFamily="Segoe UI"/>
</StackPanel>
</Button.Content>
</Button>
<!-- Weiter / Installieren -->
<Button Grid.Column="2"
Style="{StaticResource PrimaryButtonStyle}"
Command="{Binding GoNextCommand}"
Visibility="{Binding ShowNextButton,
Converter={StaticResource BoolToVisConverter}}">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="nextLabel"
Text="{Binding IsLastStep,
Converter={StaticResource LastStepLabelConverter}}"/>
<TextBlock Text=" &#xE72A;"
FontFamily="Segoe MDL2 Assets"
Visibility="{Binding IsLastStep,
Converter={StaticResource BoolToInvVisConverter}}"/>
</StackPanel>
</Button.Content>
</Button>
<!-- Fertig-Button -->
<Button Grid.Column="3"
Style="{StaticResource PrimaryButtonStyle}"
Command="{Binding FinishCommand}"
Visibility="{Binding IsCompleted,
Converter={StaticResource BoolToVisConverter}}">
<Button.Content>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE73E;"
FontFamily="Segoe MDL2 Assets"
Margin="0,0,6,0"/>
<TextBlock Text="Schließen"
FontFamily="Segoe UI"/>
</StackPanel>
</Button.Content>
</Button>
</Grid>
</Border>
</Grid>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,24 @@
using System.Windows;
using System.Windows.Input;
namespace EngineeringSync.Setup.Views;
public partial class WizardWindow : Window
{
public WizardWindow()
{
InitializeComponent();
}
private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left)
DragMove();
}
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
var vm = (ViewModels.WizardViewModel)DataContext;
vm.CancelCommand.Execute(null);
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="EngineeringSync.Setup.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- Adminrechte für Service-Installation erforderlich -->
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 11 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,11 @@
<Application x:Class="EngineeringSync.TrayApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:tb="clr-namespace:H.NotifyIcon;assembly=H.NotifyIcon.Wpf"
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
<tb:TaskbarIcon x:Key="TrayIcon"
ToolTipText="EngineeringSync Watcher aktiv"
IconSource="/Assets/tray.ico" />
</Application.Resources>
</Application>

View File

@@ -0,0 +1,139 @@
using System.Windows;
using System.Windows.Controls;
using EngineeringSync.TrayApp.Services;
using EngineeringSync.TrayApp.ViewModels;
using EngineeringSync.TrayApp.Views;
using H.NotifyIcon;
namespace EngineeringSync.TrayApp;
public partial class App : Application
{
private TaskbarIcon? _trayIcon;
private SignalRService? _signalR;
private ApiClient? _apiClient;
private PendingChangesWindow? _pendingChangesWindow;
private PendingChangesViewModel? _pendingChangesViewModel;
protected override async void OnStartup(StartupEventArgs e)
{
try
{
base.OnStartup(e);
_apiClient = new ApiClient(new System.Net.Http.HttpClient());
_signalR = new SignalRService();
// TaskbarIcon aus XAML-Resource laden (wird dadurch korrekt im Shell registriert)
_trayIcon = (TaskbarIcon)FindResource("TrayIcon");
_trayIcon.ContextMenu = BuildContextMenu();
// ForceCreate() aufrufen, um sicherzustellen dass das Icon im System Tray erscheint
_trayIcon.ForceCreate(enablesEfficiencyMode: false);
_signalR.ChangeNotificationReceived += OnChangeNotificationReceived;
_signalR.ChangeNotificationReceived += OnPendingChangesWindowNotification;
try { await _signalR.ConnectAsync(); }
catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"[TrayApp] SignalR Connect Fehler: {ex}"); }
}
catch (Exception ex)
{
// Debug-Fehlerbehandlung für Icon-Initialisierung
MessageBox.Show(
$"Fehler bei der Initialisierung des Tray Icons:\n\n{ex.Message}\n\n{ex.StackTrace}",
"EngineeringSync Startup Error",
MessageBoxButton.OK,
MessageBoxImage.Error);
throw;
}
}
private ContextMenu BuildContextMenu()
{
var menu = new ContextMenu();
var showChanges = new MenuItem { Header = "Änderungen anzeigen" };
showChanges.Click += (_, _) => OpenChangesWindow();
menu.Items.Add(showChanges);
var showProjects = new MenuItem { Header = "Projekte verwalten" };
showProjects.Click += (_, _) => OpenProjectsWindow();
menu.Items.Add(showProjects);
menu.Items.Add(new Separator());
var exit = new MenuItem { Header = "Beenden" };
exit.Click += (_, _) => ExitApp();
menu.Items.Add(exit);
return menu;
}
private void OpenChangesWindow()
{
// Fenster beim ersten Mal erstellen
if (_pendingChangesWindow is null)
{
_pendingChangesViewModel = new PendingChangesViewModel(_apiClient!);
_pendingChangesWindow = new PendingChangesWindow(_pendingChangesViewModel);
}
// Fenster anzeigen/aktivieren
_pendingChangesWindow.Show();
_pendingChangesWindow.Activate();
_ = _pendingChangesViewModel!.LoadProjectsCommand.ExecuteAsync(null);
}
private void OpenProjectsWindow()
{
var existing = Windows.OfType<ProjectManagementWindow>().FirstOrDefault();
if (existing is not null) { existing.Activate(); return; }
var vm = new ProjectManagementViewModel(_apiClient!);
var window = new ProjectManagementWindow(vm);
window.Show();
_ = vm.LoadCommand.ExecuteAsync(null);
}
private void OnChangeNotificationReceived(Guid projectId, string projectName, int count)
{
Dispatcher.Invoke(() =>
{
_trayIcon?.ShowNotification(
title: "Neue Engineering-Daten",
message: $"Projekt \"{projectName}\": {count} ausstehende Änderung(en).");
});
}
private void OnPendingChangesWindowNotification(Guid projectId, string projectName, int count)
{
// Handler für PendingChangesWindow: nur aktualisieren wenn Fenster sichtbar ist
if (_pendingChangesWindow?.IsVisible == true && _pendingChangesViewModel is not null)
{
Dispatcher.Invoke(() => _ = _pendingChangesViewModel.LoadChangesCommand.ExecuteAsync(null));
}
}
private async void ExitApp()
{
if (_signalR is not null)
{
await _signalR.DisposeAsync();
_signalR = null;
}
_trayIcon?.Dispose();
Shutdown();
}
protected override async void OnExit(ExitEventArgs e)
{
if (_signalR is not null)
{
await _signalR.DisposeAsync();
_signalR = null;
}
_trayIcon?.Dispose();
base.OnExit(e);
}
}

View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@@ -0,0 +1,32 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace EngineeringSync.TrayApp.Converters;
/// <summary>Gibt "Neues Projekt" oder "Projekt bearbeiten" zurück.</summary>
public class NewEditHeaderConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is true ? "Neues Projekt anlegen" : "Projekt bearbeiten";
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>Gibt Visibility.Visible zurück wenn der String nicht leer ist.</summary>
public class StringToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
string.IsNullOrEmpty(value as string) ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>Invertiert Boolean für Visibility (true = Collapsed, false = Visible).</summary>
public class InverseBoolToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is true ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\EngineeringSync.Domain\EngineeringSync.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.5" />
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<!-- Kein Konsolenfenster im Hintergrund -->
<ApplicationIcon>Assets\tray.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Resource Include="Assets\tray.ico" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
<Window x:Class="EngineeringSync.TrayApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:EngineeringSync.TrayApp"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
<Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,23 @@
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace EngineeringSync.TrayApp;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,59 @@
using System.Net.Http;
using System.Net.Http.Json;
using EngineeringSync.Domain.Entities;
namespace EngineeringSync.TrayApp.Services;
public class ApiClient(HttpClient http)
{
private const string Base = "http://localhost:5050/api";
public Task<List<ProjectConfig>?> GetProjectsAsync() =>
http.GetFromJsonAsync<List<ProjectConfig>>($"{Base}/projects");
public Task<List<PendingChange>?> GetChangesAsync(Guid projectId) =>
http.GetFromJsonAsync<List<PendingChange>>($"{Base}/changes/{projectId}");
public Task<List<ScanResultDto>?> ScanFoldersAsync(string engineeringPath, string simulationPath, string fileExtensions) =>
http.GetFromJsonAsync<List<ScanResultDto>>($"{Base}/projects/scan?engineeringPath={Uri.EscapeDataString(engineeringPath)}&simulationPath={Uri.EscapeDataString(simulationPath)}&fileExtensions={Uri.EscapeDataString(fileExtensions)}");
public async Task CreateProjectAsync(CreateProjectDto dto)
{
var resp = await http.PostAsJsonAsync($"{Base}/projects", dto);
resp.EnsureSuccessStatusCode();
}
public async Task UpdateProjectAsync(Guid id, UpdateProjectDto dto)
{
var resp = await http.PutAsJsonAsync($"{Base}/projects/{id}", dto);
resp.EnsureSuccessStatusCode();
}
public async Task DeleteProjectAsync(Guid id)
{
var resp = await http.DeleteAsync($"{Base}/projects/{id}");
resp.EnsureSuccessStatusCode();
}
public async Task SyncChangesAsync(List<Guid> ids)
{
var resp = await http.PostAsJsonAsync($"{Base}/sync", new { ChangeIds = ids });
resp.EnsureSuccessStatusCode();
}
public async Task IgnoreChangesAsync(List<Guid> ids)
{
var resp = await http.PostAsJsonAsync($"{Base}/ignore", new { ChangeIds = ids });
resp.EnsureSuccessStatusCode();
}
}
public record CreateProjectDto(string Name, string EngineeringPath, string SimulationPath,
string FileExtensions, bool IsActive = true, bool BackupEnabled = true,
string? BackupPath = null, int MaxBackupsPerFile = 0);
public record UpdateProjectDto(string Name, string EngineeringPath, string SimulationPath,
string FileExtensions, bool IsActive, bool BackupEnabled = true,
string? BackupPath = null, int MaxBackupsPerFile = 0);
public record ScanResultDto(string RelativePath, string ChangeType, long Size, DateTime LastModified);

View File

@@ -0,0 +1,57 @@
using EngineeringSync.Domain.Constants;
using Microsoft.AspNetCore.SignalR.Client;
namespace EngineeringSync.TrayApp.Services;
public class SignalRService : IAsyncDisposable
{
private HubConnection? _connection;
public event Action<Guid, string, int>? ChangeNotificationReceived;
public event Action? ProjectConfigChanged;
public async Task ConnectAsync(CancellationToken ct = default)
{
_connection = new HubConnectionBuilder()
.WithUrl("http://localhost:5050/notifications")
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)])
.Build();
// Register handlers on initial connection
RegisterHandlers();
// Re-register handlers when reconnected after disconnection
_connection.Reconnected += async (connectionId) =>
{
RegisterHandlers();
await Task.CompletedTask;
};
await _connection.StartAsync(ct);
}
private void RegisterHandlers()
{
if (_connection is null)
return;
// Remove existing handlers to prevent duplicates
_connection.Remove(HubMethodNames.ReceiveChangeNotification);
_connection.Remove(HubMethodNames.ProjectConfigChanged);
// Register handlers
_connection.On<Guid, string, int>(HubMethodNames.ReceiveChangeNotification,
(projectId, projectName, count) =>
ChangeNotificationReceived?.Invoke(projectId, projectName, count));
_connection.On(HubMethodNames.ProjectConfigChanged,
() => ProjectConfigChanged?.Invoke());
}
public async ValueTask DisposeAsync()
{
if (_connection is not null)
await _connection.DisposeAsync();
}
}

View File

@@ -0,0 +1,117 @@
using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using EngineeringSync.Domain.Entities;
using EngineeringSync.TrayApp.Services;
namespace EngineeringSync.TrayApp.ViewModels;
public partial class PendingChangesViewModel(ApiClient api) : ObservableObject
{
[ObservableProperty] private ObservableCollection<ProjectConfig> _projects = [];
[ObservableProperty] private ProjectConfig? _selectedProject;
[ObservableProperty] private ObservableCollection<PendingChangeItem> _changes = [];
[ObservableProperty] private bool _isLoading;
[ObservableProperty] private string _statusMessage = string.Empty;
partial void OnSelectedProjectChanged(ProjectConfig? value)
{
if (value is not null)
_ = LoadChangesAsync();
}
[RelayCommand]
public async Task LoadProjectsAsync()
{
IsLoading = true;
try
{
var projects = await api.GetProjectsAsync() ?? [];
Projects = new ObservableCollection<ProjectConfig>(projects);
if (SelectedProject is null && projects.Count > 0)
SelectedProject = projects[0];
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TrayApp] LoadProjects Fehler: {ex}");
StatusMessage = "Service nicht erreichbar.";
}
finally { IsLoading = false; }
}
[RelayCommand]
public async Task LoadChangesAsync()
{
if (SelectedProject is null) return;
IsLoading = true;
try
{
var changes = await api.GetChangesAsync(SelectedProject.Id) ?? [];
Changes = new ObservableCollection<PendingChangeItem>(
changes.Select(c => new PendingChangeItem(c)));
StatusMessage = $"{Changes.Count} ausstehende Änderung(en)";
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TrayApp] LoadChanges Fehler: {ex}");
StatusMessage = "Fehler beim Laden.";
}
finally { IsLoading = false; }
}
[RelayCommand]
public async Task SyncSelectedAsync()
{
var ids = Changes.Where(c => c.IsSelected).Select(c => c.Change.Id).ToList();
if (ids.Count == 0) return;
IsLoading = true;
try
{
await api.SyncChangesAsync(ids);
await LoadChangesAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TrayApp] SyncSelected Fehler: {ex}");
StatusMessage = "Sync fehlgeschlagen.";
}
finally { IsLoading = false; }
}
[RelayCommand]
public async Task IgnoreSelectedAsync()
{
var ids = Changes.Where(c => c.IsSelected).Select(c => c.Change.Id).ToList();
if (ids.Count == 0) return;
IsLoading = true;
try
{
await api.IgnoreChangesAsync(ids);
await LoadChangesAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TrayApp] IgnoreSelected Fehler: {ex}");
StatusMessage = "Ignore fehlgeschlagen.";
}
finally { IsLoading = false; }
}
[RelayCommand]
public void SelectAll() { foreach (var c in Changes) c.IsSelected = true; }
[RelayCommand]
public void SelectNone() { foreach (var c in Changes) c.IsSelected = false; }
}
public partial class PendingChangeItem(PendingChange change) : ObservableObject
{
public PendingChange Change { get; } = change;
[ObservableProperty] private bool _isSelected;
public string FileName => Path.GetFileName(Change.RelativePath);
public string RelativePath => Change.RelativePath;
public string ChangeTypeDisplay => Change.ChangeType.ToString();
public string CreatedAtDisplay => Change.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss");
}

View File

@@ -0,0 +1,183 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using EngineeringSync.Domain.Entities;
using EngineeringSync.TrayApp.Services;
namespace EngineeringSync.TrayApp.ViewModels;
public partial class ProjectManagementViewModel(ApiClient api) : ObservableObject
{
[ObservableProperty] private ObservableCollection<ProjectConfig> _projects = [];
[ObservableProperty] private ProjectConfig? _selectedProject;
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _editName = string.Empty;
[ObservableProperty] private string _editEngineeringPath = string.Empty;
[ObservableProperty] private string _editSimulationPath = string.Empty;
[ObservableProperty] private string _editFileExtensions = ".jt,.cojt,.xml";
[ObservableProperty] private bool _editIsActive = true;
[ObservableProperty] private bool _editBackupEnabled = true;
[ObservableProperty] private bool _editBackupUseCustomPath = false;
[ObservableProperty] private string _editBackupCustomPath = string.Empty;
[ObservableProperty] private int _editMaxBackupsPerFile = 0;
[ObservableProperty] private bool _isNewProject;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private string _scanStatus = string.Empty;
[ObservableProperty] private ObservableCollection<ScanResultDto> _scanResults = [];
public bool HasScanResults => ScanResults.Count > 0;
[RelayCommand]
public async Task LoadAsync()
{
var projects = await api.GetProjectsAsync() ?? [];
Projects = new ObservableCollection<ProjectConfig>(projects);
}
[RelayCommand]
public void StartNewProject()
{
SelectedProject = null;
IsNewProject = true;
IsEditing = true;
EditName = string.Empty;
EditEngineeringPath = string.Empty;
EditSimulationPath = string.Empty;
EditFileExtensions = ".jt,.cojt,.xml";
EditIsActive = true;
EditBackupEnabled = true;
EditBackupUseCustomPath = false;
EditBackupCustomPath = string.Empty;
EditMaxBackupsPerFile = 0;
}
[RelayCommand]
public void EditProject(ProjectConfig? project)
{
if (project is null)
{
System.Windows.MessageBox.Show(
"Bitte zuerst ein Projekt auswählen.",
"EngineeringSync",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Information);
return;
}
SelectedProject = project;
IsNewProject = false;
IsEditing = true;
EditName = project.Name;
EditEngineeringPath = project.EngineeringPath;
EditSimulationPath = project.SimulationPath;
EditFileExtensions = project.FileExtensions;
EditIsActive = project.IsActive;
EditBackupEnabled = project.BackupEnabled;
EditBackupUseCustomPath = project.BackupPath is not null;
EditBackupCustomPath = project.BackupPath ?? string.Empty;
EditMaxBackupsPerFile = project.MaxBackupsPerFile;
}
[RelayCommand]
public async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(EditName))
{
StatusMessage = "Name darf nicht leer sein.";
return;
}
try
{
if (IsNewProject)
await api.CreateProjectAsync(new CreateProjectDto(
EditName, EditEngineeringPath, EditSimulationPath, EditFileExtensions, EditIsActive,
EditBackupEnabled,
EditBackupUseCustomPath ? EditBackupCustomPath : null,
EditMaxBackupsPerFile));
else
await api.UpdateProjectAsync(SelectedProject!.Id, new UpdateProjectDto(
EditName, EditEngineeringPath, EditSimulationPath, EditFileExtensions, EditIsActive,
EditBackupEnabled,
EditBackupUseCustomPath ? EditBackupCustomPath : null,
EditMaxBackupsPerFile));
IsEditing = false;
await LoadAsync();
StatusMessage = IsNewProject ? "Projekt erstellt." : "Projekt aktualisiert.";
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TrayApp] Save Fehler: {ex}");
StatusMessage = $"Fehler: {ex.Message}";
}
}
[RelayCommand]
public void Cancel() => IsEditing = false;
[RelayCommand]
public async Task DeleteAsync(ProjectConfig? project)
{
if (project is null)
{
System.Windows.MessageBox.Show(
"Bitte zuerst ein Projekt auswählen.",
"EngineeringSync",
System.Windows.MessageBoxButton.OK,
System.Windows.MessageBoxImage.Information);
return;
}
try
{
await api.DeleteProjectAsync(project.Id);
await LoadAsync();
StatusMessage = "Projekt gelöscht.";
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[TrayApp] Save Fehler: {ex}");
StatusMessage = $"Fehler: {ex.Message}";
}
}
[RelayCommand]
public async Task ScanFoldersAsync()
{
if (string.IsNullOrWhiteSpace(EditEngineeringPath) || string.IsNullOrWhiteSpace(EditSimulationPath))
{
ScanStatus = "Bitte beide Ordnerpfade angeben.";
return;
}
if (!System.IO.Directory.Exists(EditEngineeringPath))
{
ScanStatus = $"Engineering-Ordner existiert nicht: {EditEngineeringPath}";
return;
}
if (!System.IO.Directory.Exists(EditSimulationPath))
{
ScanStatus = $"Simulations-Ordner existiert nicht: {EditSimulationPath}";
return;
}
try
{
ScanStatus = "Wird gescannt...";
ScanResults.Clear();
var results = await api.ScanFoldersAsync(EditEngineeringPath, EditSimulationPath, EditFileExtensions);
if (results is null || results.Count == 0)
{
ScanStatus = "Keine Unterschiede gefunden - Ordner sind synchron.";
}
else
{
ScanStatus = $"{results.Count} Unterschied(e) gefunden:";
foreach (var r in results)
ScanResults.Add(r);
}
}
catch (Exception ex)
{
ScanStatus = $"Scan-Fehler: {ex.Message}";
}
}
}

View File

@@ -0,0 +1,67 @@
<Window x:Class="EngineeringSync.TrayApp.Views.PendingChangesWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Ausstehende Änderungen EngineeringSync"
Height="520" Width="860"
WindowStartupLocation="CenterScreen"
Background="#F5F5F5">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Projekt-Auswahl -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Projekt:" VerticalAlignment="Center" Margin="0,0,8,0" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding Projects}"
SelectedItem="{Binding SelectedProject}"
DisplayMemberPath="Name"
Width="300" />
<Button Content="Aktualisieren" Margin="8,0,0,0" Padding="10,4"
Command="{Binding LoadChangesCommand}" />
</StackPanel>
<!-- Änderungsliste -->
<DataGrid Grid.Row="1"
ItemsSource="{Binding Changes}"
AutoGenerateColumns="False"
CanUserAddRows="False"
CanUserDeleteRows="False"
SelectionMode="Single"
GridLinesVisibility="Horizontal"
Background="White"
BorderBrush="#DDDDDD"
BorderThickness="1">
<DataGrid.Columns>
<DataGridCheckBoxColumn Binding="{Binding IsSelected, UpdateSourceTrigger=PropertyChanged}"
Header="" Width="40" />
<DataGridTextColumn Binding="{Binding FileName}" Header="Datei" Width="200" IsReadOnly="True"/>
<DataGridTextColumn Binding="{Binding RelativePath}" Header="Pfad" Width="*" IsReadOnly="True"/>
<DataGridTextColumn Binding="{Binding ChangeTypeDisplay}" Header="Änderungstyp" Width="120" IsReadOnly="True"/>
<DataGridTextColumn Binding="{Binding CreatedAtDisplay}" Header="Zeitpunkt" Width="160" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
<!-- Auswahl-Buttons -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,4">
<Button Content="Alle auswählen" Padding="8,4" Margin="0,0,4,0"
Command="{Binding SelectAllCommand}" />
<Button Content="Keine auswählen" Padding="8,4"
Command="{Binding SelectNoneCommand}" />
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center"
Margin="16,0,0,0" Foreground="Gray" />
</StackPanel>
<!-- Aktions-Buttons -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,4,0,0">
<Button Content="Ausgewählte ignorieren" Padding="10,6" Margin="0,0,8,0"
Command="{Binding IgnoreSelectedCommand}" />
<Button Content="Ausgewählte synchronisieren" Padding="10,6"
Background="#0078D4" Foreground="White" BorderThickness="0"
Command="{Binding SyncSelectedCommand}" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,13 @@
using System.Windows;
using EngineeringSync.TrayApp.ViewModels;
namespace EngineeringSync.TrayApp.Views;
public partial class PendingChangesWindow : Window
{
public PendingChangesWindow(PendingChangesViewModel vm)
{
InitializeComponent();
DataContext = vm;
}
}

View File

@@ -0,0 +1,215 @@
<Window x:Class="EngineeringSync.TrayApp.Views.ProjectManagementWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:EngineeringSync.TrayApp.Converters"
Title="Projektverwaltung EngineeringSync"
Height="800" Width="800"
WindowStartupLocation="CenterScreen"
Background="#F5F5F5">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisConverter"/>
<conv:NewEditHeaderConverter x:Key="NewEditHeaderConverter"/>
<conv:StringToVisibilityConverter x:Key="StringToVisConverter"/>
<conv:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisConverter"/>
</Window.Resources>
<Grid Margin="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="350" />
</Grid.ColumnDefinitions>
<!-- Linke Spalte: Projektliste -->
<Grid Grid.Column="0" Margin="0,0,12,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Überwachte Projekte" FontWeight="Bold" FontSize="14" Margin="0,0,0,8"/>
<ListView Grid.Row="1" ItemsSource="{Binding Projects}"
SelectedItem="{Binding SelectedProject}"
Background="White" BorderBrush="#DDDDDD">
<ListView.ItemTemplate>
<DataTemplate>
<StackPanel Margin="4">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
<TextBlock Text="{Binding EngineeringPath}" FontSize="11" Foreground="Gray" />
<StackPanel Orientation="Horizontal">
<TextBlock Text="Aktiv: " FontSize="11" Foreground="Gray"/>
<TextBlock Text="{Binding IsActive}" FontSize="11" Foreground="Gray"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="+ Neues Projekt" Padding="10,6" Margin="0,0,8,0"
Command="{Binding StartNewProjectCommand}" />
<Button Content="Bearbeiten" Padding="10,6" Margin="0,0,8,0"
Command="{Binding EditProjectCommand}"
CommandParameter="{Binding SelectedProject}" />
<Button Content="Löschen" Padding="10,6" Foreground="Red"
Command="{Binding DeleteCommand}"
CommandParameter="{Binding SelectedProject}" />
</StackPanel>
</Grid>
<!-- Rechte Spalte: Editierformular -->
<Border Grid.Column="1"
Background="White"
BorderBrush="#DDDDDD"
BorderThickness="1"
CornerRadius="4"
Padding="16"
Visibility="{Binding IsEditing, Converter={StaticResource BoolToVisConverter}}">
<StackPanel>
<TextBlock Text="{Binding IsNewProject, Converter={StaticResource NewEditHeaderConverter}}"
FontWeight="Bold" FontSize="14" Margin="0,0,0,16"/>
<TextBlock Text="Name:" Margin="0,0,0,4" FontWeight="SemiBold"/>
<TextBox Text="{Binding EditName, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,12" Padding="6"/>
<TextBlock Text="Engineering-Pfad:" Margin="0,0,0,4" FontWeight="SemiBold"/>
<DockPanel Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Content="..." Width="32" Margin="4,0,0,0"
Click="BrowseEngineering_Click"/>
<TextBox Text="{Binding EditEngineeringPath, UpdateSourceTrigger=PropertyChanged}" Padding="6"/>
</DockPanel>
<TextBlock Text="Simulations-Pfad:" Margin="0,0,0,4" FontWeight="SemiBold"/>
<DockPanel Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Content="..." Width="32" Margin="4,0,0,0"
Click="BrowseSimulation_Click"/>
<TextBox Text="{Binding EditSimulationPath, UpdateSourceTrigger=PropertyChanged}" Padding="6"/>
</DockPanel>
<TextBlock Text="Dateiendungen (komma-getrennt):" Margin="0,0,0,4" FontWeight="SemiBold"/>
<TextBox Text="{Binding EditFileExtensions, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,12" Padding="6">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Text" Value="">
<Setter Property="Background">
<Setter.Value>
<VisualBrush AlignmentX="Left" AlignmentY="Center" Stretch="None">
<VisualBrush.Visual>
<TextBlock Text="z.B. .jt,.cojt (leer = alle Dateien)" Foreground="#999999" Margin="6,0" FontSize="11"/>
</VisualBrush.Visual>
</VisualBrush>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<CheckBox Content="Aktiv (Watcher läuft)"
IsChecked="{Binding EditIsActive}"
Margin="0,0,0,16"/>
<!-- ORDNER-PRÜFUNG -->
<Border Background="#E3F2FD" BorderBrush="#90CAF9" BorderThickness="1"
CornerRadius="4" Padding="12" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="Unterschiede prüfen" FontWeight="SemiBold" Margin="0,0,0,6"/>
<TextBlock Text="Prüft beide Ordner und zeigt Unterschiede vor dem Speichern."
FontSize="11" Foreground="#555555" Margin="0,0,0,8" TextWrapping="Wrap"/>
<Button Content="Ordner prüfen..." Padding="12,6"
Command="{Binding ScanFoldersCommand}"
HorizontalAlignment="Left"/>
<TextBlock Text="{Binding ScanStatus}" FontSize="11" Foreground="#1976D2"
Margin="0,4,0,0" TextWrapping="Wrap"
Visibility="{Binding ScanStatus, Converter={StaticResource StringToVisConverter}}"/>
</StackPanel>
</Border>
<!-- Scan-Ergebnisse -->
<ItemsControl ItemsSource="{Binding ScanResults}" Margin="0,0,0,16"
Visibility="{Binding HasScanResults, Converter={StaticResource BoolToVisConverter}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#FFF8E1" BorderBrush="#FFD54F" BorderThickness="1"
CornerRadius="4" Padding="8" Margin="0,4">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding ChangeType}" FontWeight="SemiBold"
Foreground="#F57C00" Width="70"/>
<TextBlock Text="{Binding RelativePath}" FontWeight="SemiBold"/>
</StackPanel>
<TextBlock FontSize="11" Foreground="#666666">
<Run Text="{Binding Size, StringFormat='{}{0:N0} Bytes'}"/>
<Run Text=" • "/>
<Run Text="{Binding LastModified, StringFormat='yyyy-MM-dd HH:mm'}"/>
</TextBlock>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- BACKUP-Sektion -->
<Separator Margin="0,8,0,12"/>
<TextBlock Text="BACKUP" FontSize="10" FontWeight="Bold" Foreground="#888888"
Margin="0,0,0,8"/>
<CheckBox Content="Backups vor dem Überschreiben erstellen"
IsChecked="{Binding EditBackupEnabled}"
Margin="0,0,0,10"/>
<StackPanel Visibility="{Binding EditBackupEnabled,
Converter={StaticResource BoolToVisConverter}}">
<TextBlock Text="Speicherort:" FontWeight="SemiBold" Margin="0,0,0,6"/>
<!-- Mode=OneWay: gegenseitiger Ausschluss läuft über GroupName + TwoWay am zweiten Radio -->
<RadioButton GroupName="BackupLocation" Content="Gleicher Ordner wie Simulationsdatei"
IsChecked="{Binding EditBackupUseCustomPath,
Converter={StaticResource InverseBoolToVisConverter},
Mode=OneWay}"
Margin="0,0,0,4"/>
<RadioButton GroupName="BackupLocation" Content="Eigener Backup-Ordner"
IsChecked="{Binding EditBackupUseCustomPath, Mode=TwoWay}"
Margin="0,0,0,6"/>
<DockPanel Margin="0,0,0,10"
Visibility="{Binding EditBackupUseCustomPath,
Converter={StaticResource BoolToVisConverter}}">
<Button DockPanel.Dock="Right" Content="..." Width="32" Margin="4,0,0,0"
Click="BrowseBackup_Click"/>
<TextBox Text="{Binding EditBackupCustomPath, UpdateSourceTrigger=PropertyChanged}"
Padding="6"/>
</DockPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Max. Backups pro Datei:" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox Width="50" Padding="4,2"
Text="{Binding EditMaxBackupsPerFile, UpdateSourceTrigger=PropertyChanged}"
VerticalContentAlignment="Center"/>
<TextBlock Text="(0 = unbegrenzt)" FontSize="11" Foreground="Gray"
VerticalAlignment="Center" Margin="6,0,0,0"/>
</StackPanel>
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Foreground="OrangeRed"
TextWrapping="Wrap" Margin="0,0,0,8"
Visibility="{Binding StatusMessage, Converter={StaticResource StringToVisConverter}}"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Abbrechen" Padding="10,6" Margin="0,0,8,0"
Command="{Binding CancelCommand}" />
<Button Content="Speichern" Padding="10,6"
Background="#0078D4" Foreground="White" BorderThickness="0"
Command="{Binding SaveCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,52 @@
using System.Windows;
using EngineeringSync.TrayApp.ViewModels;
using Microsoft.Win32;
namespace EngineeringSync.TrayApp.Views;
public partial class ProjectManagementWindow : Window
{
private readonly ProjectManagementViewModel _vm;
public ProjectManagementWindow(ProjectManagementViewModel vm)
{
InitializeComponent();
_vm = vm;
DataContext = vm;
}
private void BrowseEngineering_Click(object sender, RoutedEventArgs e)
{
var dialog = new OpenFolderDialog
{
Title = "Engineering-Quellpfad wählen",
InitialDirectory = _vm.EditEngineeringPath
};
if (dialog.ShowDialog() == true)
_vm.EditEngineeringPath = dialog.FolderName;
}
private void BrowseSimulation_Click(object sender, RoutedEventArgs e)
{
var dialog = new OpenFolderDialog
{
Title = "Simulations-Zielpfad wählen",
InitialDirectory = _vm.EditSimulationPath
};
if (dialog.ShowDialog() == true)
_vm.EditSimulationPath = dialog.FolderName;
}
private void BrowseBackup_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog
{
Title = "Backup-Verzeichnis wählen",
InitialDirectory = string.IsNullOrEmpty(_vm.EditBackupCustomPath)
? null
: _vm.EditBackupCustomPath
};
if (dlg.ShowDialog() == true)
_vm.EditBackupCustomPath = dlg.FolderName;
}
}

7
EngineeringSync.slnx Normal file
View File

@@ -0,0 +1,7 @@
<Solution>
<Project Path="EngineeringSync.Domain/EngineeringSync.Domain.csproj" />
<Project Path="EngineeringSync.Infrastructure/EngineeringSync.Infrastructure.csproj" />
<Project Path="EngineeringSync.Service/EngineeringSync.Service.csproj" />
<Project Path="EngineeringSync.Setup/EngineeringSync.Setup.csproj" />
<Project Path="EngineeringSync.TrayApp/EngineeringSync.TrayApp.csproj" />
</Solution>

712
HANDBUCH.md Normal file
View File

@@ -0,0 +1,712 @@
# EngineeringSync Benutzerhandbuch
**Version 1.0.0** | Stand: März 2026
---
## Inhaltsverzeichnis
1. [Einführung](#1-einführung)
2. [Systemvoraussetzungen](#2-systemvoraussetzungen)
3. [Installation](#3-installation)
- 3.1 [Setup-Assistent starten](#31-setup-assistent-starten)
- 3.2 [Schritt: Willkommen](#32-schritt-willkommen)
- 3.3 [Schritt: Installation](#33-schritt-installation)
- 3.4 [Schritt: Erstes Projekt konfigurieren](#34-schritt-erstes-projekt-konfigurieren)
- 3.5 [Schritt: Backup-Einstellungen](#35-schritt-backup-einstellungen)
- 3.6 [Schritt: Start- und Verknüpfungs-Optionen](#36-schritt-start--und-verknüpfungs-optionen)
- 3.7 [Schritt: Zusammenfassung & Installation](#37-schritt-zusammenfassung--installation)
- 3.8 [Schritt: Fertig](#38-schritt-fertig)
4. [Die Tray-App im Überblick](#4-die-tray-app-im-überblick)
5. [Projekte verwalten](#5-projekte-verwalten)
- 5.1 [Neues Projekt anlegen](#51-neues-projekt-anlegen)
- 5.2 [Projekt bearbeiten](#52-projekt-bearbeiten)
- 5.3 [Projekt löschen](#53-projekt-löschen)
- 5.4 [Dateiüberwachung konfigurieren](#54-dateiüberwachung-konfigurieren)
6. [Änderungen überwachen und synchronisieren](#6-änderungen-überwachen-und-synchronisieren)
- 6.1 [Benachrichtigungen empfangen](#61-benachrichtigungen-empfangen)
- 6.2 [Das Fenster „Ausstehende Änderungen"](#62-das-fenster-ausstehende-änderungen)
- 6.3 [Änderungen synchronisieren](#63-änderungen-synchronisieren)
- 6.4 [Änderungen ignorieren](#64-änderungen-ignorieren)
- 6.5 [Änderungshistorie](#65-änderungshistorie)
7. [Backup-Funktion](#7-backup-funktion)
8. [Windows-Dienst verwalten](#8-windows-dienst-verwalten)
9. [Deinstallation](#9-deinstallation)
10. [Technische Architektur](#10-technische-architektur)
11. [Fehlerbehebung & FAQ](#11-fehlerbehebung--faq)
---
## 1. Einführung
**EngineeringSync** ist ein Middleware-Werkzeug, das die Arbeit zwischen der Konstruktionsabteilung (CAD) und der Simulationsabteilung (z. B. Process Simulate) koordiniert.
### Das Problem ohne EngineeringSync
In der täglichen Praxis arbeiten Konstrukteure und Simulationsingenieure an denselben Dateien, aber in unterschiedlichen Verzeichnissen. Dateien werden manuell kopiert oft zur falschen Zeit, in der falschen Version, oder sogar während eine Simulation läuft. Das kann zu:
- **Datenverlust** führen (Überschreiben aktiver Simulationsdateien)
- **Inkonsistenz** erzeugen (Simulation basiert auf veralteten CAD-Daten)
- **Zeitverlust** verursachen (manuelles Tracking von Änderungen)
### Die Lösung mit EngineeringSync
```
Konstrukteur Simulationsingenieur
│ │
│ speichert CAD-Datei │
▼ │
Engineering-Ordner Simulations-Ordner
(wird überwacht) (Sync-Ziel)
│ │
└──────── EngineeringSync ──────────┘
├─ erkennt Änderungen automatisch
├─ benachrichtigt den Sim-Ingenieur
├─ wartet auf Freigabe
└─ kopiert erst nach Bestätigung
```
Der Simulationsingenieur behält die volle Kontrolle: Dateien werden **nie automatisch** überschrieben erst nach expliziter Freigabe durch die Tray-App.
---
## 2. Systemvoraussetzungen
| Komponente | Mindestanforderung |
|---|---|
| Betriebssystem | Windows 10 (64-Bit) oder Windows 11 |
| .NET Runtime | .NET 10 (wird vom Installer mitgeliefert) |
| Arbeitsspeicher | 256 MB RAM (Dienst) |
| Festplatte | 150 MB freier Speicher |
| Netzwerk | Keine (rein lokal, `localhost:5050`) |
| Benutzerrechte | Administratorrechte für die Installation |
> **Hinweis:** EngineeringSync läuft ausschließlich lokal auf einem Rechner. Netzwerkfreigaben können als überwachte Pfade genutzt werden, sofern sie als Laufwerksbuchstabe eingebunden sind.
---
## 3. Installation
### 3.1 Setup-Assistent starten
Führen Sie die Installationsdatei `EngineeringSync-Setup.exe` aus. Falls eine Sicherheitswarnung von Windows erscheint, klicken Sie auf **„Weitere Informationen"** → **„Trotzdem ausführen"**.
Der Setup-Assistent öffnet sich als modernes Fenster mit einer linken Navigationsleiste, die den aktuellen Fortschritt anzeigt.
---
### 3.2 Schritt: Willkommen
Der erste Schritt begrüßt Sie und gibt einen Überblick über die installierten Komponenten:
- **EngineeringSync-Dienst** der Hintergrunddienst, der Dateien überwacht
- **Tray-App** die Benutzeroberfläche im Windows-Systemtray
- **SQLite-Datenbank** lokale Speicherung aller Änderungen und Konfigurationen
Klicken Sie auf **„Weiter →"** um fortzufahren.
---
### 3.3 Schritt: Installation
Wählen Sie den **Installationsordner**. Der Standardpfad lautet:
```
C:\Program Files\EngineeringSync\
```
Sie können über das Ordner-Symbol einen anderen Pfad wählen. Klicken Sie auf **„Weiter →"**.
---
### 3.4 Schritt: Erstes Projekt konfigurieren
Dies ist der wichtigste Konfigurationsschritt. Sie definieren hier Ihr erstes überwachtes Projekt.
#### Projektname
Vergeben Sie einen aussagekräftigen Namen, z. B. `Linie 3 Roboterzelle` oder `Projekt Alpha`.
#### Engineering-Quellpfad *(wird überwacht)*
Der Ordner, in dem der **Konstrukteur** arbeitet und CAD-Dateien speichert. Jede Änderung in diesem Verzeichnis wird automatisch erfasst und dem Simulationsingenieur gemeldet.
Beispiel: `C:\CAD-Projekte\Linie3\`
> Klicken Sie auf das **Ordner-Symbol** rechts neben dem Eingabefeld um einen Ordner per Dialog auszuwählen.
#### Simulations-Zielpfad *(Sync-Ziel)*
Der Ordner, in dem der **Simulationsingenieur** arbeitet. Freigegebene Änderungen werden in dieses Verzeichnis kopiert (mit automatischem Backup der bestehenden Dateien).
Beispiel: `C:\ProcessSimulate\Projekte\Linie3\`
#### Überwachte Dateien
Sie können wählen, welche Dateitypen überwacht werden:
| Option | Beschreibung |
|---|---|
| **Alle Dateitypen überwachen** | Jede Dateiänderung im Ordner wird protokolliert (unabhängig von der Endung) |
| **Spezifische Endungen** | Nur bestimmte Dateitypen werden überwacht |
Für spezifische Endungen stehen Quick-Select-Buttons bereit:
| Button | Format | Verwendung |
|---|---|---|
| `.jt` | JT (Jupiter Tesselation) | Siemens NX, Teamcenter |
| `.cojt` | Compound JT | Process Simulate Assemblies |
| `.xml` | XML | Konfigurationsdateien |
| `.stp` / `.step` | STEP | CAD-Austauschformat |
| `.igs` / `.iges` | IGES | Älteres CAD-Austauschformat |
| `.prt` | Part File | NX Part-Dateien |
Sie können auch manuell Endungen (komma-getrennt) eingeben, z. B.:
`.jt, .cojt, .xml, .prt`
---
### 3.5 Schritt: Backup-Einstellungen
Bevor EngineeringSync eine Simulationsdatei überschreibt, kann automatisch eine Sicherungskopie erstellt werden.
#### Backups aktivieren
Aktivieren Sie den Schalter um Backups zu aktivieren (empfohlen). Das Backup wird unmittelbar **vor** dem Überschreiben erstellt.
Das Namensschema der Backup-Datei lautet:
`{Dateiname}_{JJJJMMTT_HHMMSS}.bak`
Beispiel: `Roboter_Arm.jt_20260315_143022.bak`
#### Backup-Speicherort
| Option | Beschreibung |
|---|---|
| **Gleicher Ordner** | Das Backup liegt neben der Originaldatei (Standard) |
| **Eigener Backup-Ordner** | Alle Backups werden zentral in einem Ordner gesammelt |
#### Aufbewahrung
Legen Sie fest, wie viele Backups **pro Datei** maximal behalten werden:
- `0` = unbegrenzt (alle Backups behalten)
- `5` = die letzten 5 Backups werden behalten, ältere werden gelöscht
---
### 3.6 Schritt: Start- und Verknüpfungs-Optionen
#### Autostart
| Option | Empfehlung | Beschreibung |
|---|---|---|
| **Windows-Dienst automatisch starten** | ✅ Empfohlen | Der Hintergrunddienst startet mit Windows |
| **Tray-App bei Anmeldung starten** | ✅ Empfohlen | Die Tray-App erscheint nach dem Login automatisch |
#### Verknüpfungen
| Option | Beschreibung |
|---|---|
| **Desktop-Verknüpfung** | Startet die Tray-App per Doppelklick vom Desktop |
| **Startmenü-Eintrag** | EngineeringSync im Windows-Startmenü |
#### Nach Installation
| Option | Beschreibung |
|---|---|
| **Tray-App nach Installation starten** | Startet die App direkt nach Abschluss |
---
### 3.7 Schritt: Zusammenfassung & Installation
Überprüfen Sie alle gewählten Einstellungen in der Zusammenfassung. Mit **„Installieren"** wird die Installation durchgeführt:
1. Dateien werden in den Installationsordner kopiert
2. Der Windows-Dienst `EngineeringSync` wird registriert
3. Der Dienst wird gestartet
4. Autostart-Einträge werden gesetzt (falls ausgewählt)
5. Verknüpfungen werden erstellt (falls ausgewählt)
Ein Fortschrittsbalken zeigt den aktuellen Status an.
---
### 3.8 Schritt: Fertig
Die Installation ist abgeschlossen. Klicken Sie auf **„Schließen"** um den Setup-Assistenten zu beenden. Falls die Option „Tray-App nach Installation starten" aktiviert war, erscheint das Tray-Icon automatisch in der Windows-Taskleiste.
---
## 4. Die Tray-App im Überblick
Die Tray-App lebt im **Windows-Systemtray** (rechte Seite der Taskleiste, neben der Uhr).
```
Taskleiste: [...] [🔔] [EngineeringSync-Icon] [Zeit]
Rechtsklick-Menü:
┌─────────────────────────────────┐
│ 🔔 Ausstehende Änderungen (3) │
│ ⚙ Projekte verwalten │
│ ─────────────────────────────── │
│ ✕ Beenden │
└─────────────────────────────────┘
```
### Tray-Icon-Zustände
| Symbol | Bedeutung |
|---|---|
| Normal | Dienst läuft, keine ausstehenden Änderungen |
| Mit Zahl | Anzahl der ausstehenden Änderungen, die auf Freigabe warten |
### Toast-Benachrichtigungen
Sobald neue Dateländerungen erkannt werden, erscheint automatisch eine Windows-Benachrichtigung:
```
EngineeringSync
───────────────
Projekt "Linie 3": 2 neue Änderungen
```
Ein Klick auf die Benachrichtigung öffnet direkt das Fenster „Ausstehende Änderungen".
---
## 5. Projekte verwalten
Über **Rechtsklick → „Projekte verwalten"** öffnet sich das Projektverwaltungs-Fenster.
### 5.1 Neues Projekt anlegen
1. Klicken Sie auf **„+ Projekt hinzufügen"**
2. Füllen Sie die Felder aus (gleiche Felder wie im Setup-Assistenten)
3. Bestätigen Sie mit **„Speichern"**
Der Dienst beginnt sofort mit der Überwachung des neuen Ordners. Ein initialer Scan vergleicht vorhandene Dateien zwischen Engineering- und Simulations-Ordner und erstellt für abweichende Dateien sofort ausstehende Änderungen.
### 5.2 Projekt bearbeiten
1. Wählen Sie ein Projekt in der Liste aus
2. Klicken Sie auf **„Bearbeiten"** (oder Doppelklick)
3. Ändern Sie die gewünschten Felder
4. Bestätigen Sie mit **„Speichern"**
> **Hinweis:** Nach dem Speichern wird der Dateiwatcher automatisch neu gestartet. Laufende Scans werden abgebrochen und neu begonnen.
### 5.3 Projekt löschen
1. Wählen Sie ein Projekt in der Liste aus
2. Klicken Sie auf **„Löschen"**
3. Bestätigen Sie die Sicherheitsfrage
> **Achtung:** Beim Löschen eines Projekts werden auch alle zugehörigen ausstehenden Änderungen und die Datei-Revisionshistorie gelöscht. Die tatsächlichen Dateien auf der Festplatte werden **nicht** verändert.
### 5.4 Dateiüberwachung konfigurieren
#### Aktive / inaktive Projekte
Projekte können vorübergehend deaktiviert werden (z. B. während Wartungsarbeiten). Ein deaktiviertes Projekt erzeugt keine neuen Änderungseinträge.
#### Dateierweiterungen
Die überwachten Dateitypen können jederzeit angepasst werden. Änderungen wirken sich auf zukünftige Ereignisse aus bestehende ausstehende Änderungen bleiben erhalten.
---
## 6. Änderungen überwachen und synchronisieren
### 6.1 Benachrichtigungen empfangen
EngineeringSync überwacht den Engineering-Ordner **in Echtzeit** mittels `FileSystemWatcher`. Folgende Ereignisse werden erfasst:
| Ereignistyp | Beschreibung |
|---|---|
| **Erstellt** | Eine neue Datei wurde im Engineering-Ordner abgelegt |
| **Geändert** | Eine bestehende Datei wurde modifiziert (SHA-256-Prüfung bestätigt tatsächliche Änderung) |
| **Umbenannt** | Eine Datei wurde umbenannt oder verschoben |
| **Gelöscht** | Eine Datei wurde aus dem Engineering-Ordner entfernt |
#### Debouncing (Entprellung)
Schnelle aufeinanderfolgende Speichervorgänge (z. B. Autosave alle 5 Sekunden) werden zu einem einzigen Ereignis zusammengefasst. Das Zeitfenster beträgt **2 Sekunden** erst nach 2 Sekunden ohne weitere Aktivität auf derselben Datei wird das Ereignis verarbeitet.
#### SHA-256-Prüfung
Bevor eine Änderung als „ausstehend" eingetragen wird, vergleicht EngineeringSync den SHA-256-Hash der Datei mit dem zuletzt gespeicherten Hash. Nur bei tatsächlich unterschiedlichem Inhalt wird eine neue Änderung erfasst. Das verhindert Falschmeldungen bei Metadaten-Änderungen (z. B. Zeitstempel-Updates).
### 6.2 Das Fenster „Ausstehende Änderungen"
Öffnen Sie das Fenster über **Rechtsklick → „Ausstehende Änderungen"** oder per Klick auf eine Toast-Benachrichtigung.
```
┌─────────────────────────────────────────────────────────────┐
│ Ausstehende Änderungen │
│ │
│ Projekt: [Linie 3 Roboterzelle ▼] │
│ │
│ ┌──────────────────────────────────────┬──────┬──────────┐ │
│ │ Datei │ Typ │ Datum │ │
│ ├──────────────────────────────────────┼──────┼──────────┤ │
│ │ ☑ Roboter\Arm_links.jt │ Mod. │ 14:30:22 │ │
│ │ ☑ Roboter\Arm_rechts.jt │ Neu │ 14:31:05 │ │
│ │ ☐ Basis\Grundplatte.jt │ Mod. │ 09:15:44 │ │
│ └──────────────────────────────────────┴──────┴──────────┘ │
│ │
│ [Alle wählen] [Keine] [✔ Sync] [✖ Ignorieren] │
└─────────────────────────────────────────────────────────────┘
```
#### Projektauswahl
Falls mehrere Projekte konfiguriert sind, wählen Sie das gewünschte Projekt aus dem Dropdown-Menü. Die Tabelle aktualisiert sich automatisch per SignalR neue Änderungen erscheinen sofort ohne manuelles Neuladen.
#### Änderungstypen
| Symbol | Typ | Bedeutung |
|---|---|---|
| **Neu** | Created | Datei existiert noch nicht im Simulations-Ordner |
| **Mod.** | Modified | Datei wurde inhaltlich geändert (anderer SHA-256) |
| **Umbn.** | Renamed | Datei wurde umbenannt (Alter Name wird im Sim-Ordner entfernt) |
| **Gel.** | Deleted | Datei wurde aus dem Engineering-Ordner gelöscht |
### 6.3 Änderungen synchronisieren
1. Wählen Sie die gewünschten Änderungen per Checkbox aus (oder **„Alle wählen"**)
2. Klicken Sie auf **„✔ Sync"**
EngineeringSync verarbeitet jede ausgewählte Änderung:
**Bei „Erstellt" oder „Geändert":**
```
1. Backup der bestehenden Simulationsdatei erstellen (falls Backup aktiviert)
2. Zielverzeichnis erstellen (falls nicht vorhanden)
3. Datei aus Engineering-Ordner in Simulations-Ordner kopieren
4. Änderung als "Synchronisiert" markieren
```
**Bei „Gelöscht":**
```
1. Backup der Simulationsdatei erstellen (falls Backup aktiviert)
2. Datei aus Simulations-Ordner entfernen
3. Änderung als "Synchronisiert" markieren
```
**Bei „Umbenannt":**
```
1. Neue Datei unter neuem Namen in Simulations-Ordner kopieren
2. Alte Datei aus Simulations-Ordner entfernen
3. Änderung als "Synchronisiert" markieren
```
> **Tipp:** Sie müssen nicht auf alle Änderungen gleichzeitig reagieren. Wählen Sie gezielt die Dateien aus, die für die aktuell laufende Simulation relevant sind.
### 6.4 Änderungen ignorieren
Änderungen, die für die Simulation nicht relevant sind (z. B. temporäre Hilfsdateien, Arbeitsversionen), können ignoriert werden:
1. Wählen Sie die irrelevanten Änderungen per Checkbox aus
2. Klicken Sie auf **„✖ Ignorieren"**
Ignorierte Änderungen werden in der Hauptliste ausgeblendet und in der **Historienliste** mit dem Status „Ignoriert" gespeichert. Sie haben **keinen Einfluss** auf den Simulations-Ordner.
### 6.5 Änderungshistorie
Die Historienliste zeigt die letzten 100 bearbeiteten Änderungen (synchronisiert oder ignoriert) mit Zeitstempel und Bearbeiter-Status. Die Ansicht dient zur Nachvollziehbarkeit: Wann wurde welche Datei zuletzt synchronisiert?
---
## 7. Backup-Funktion
Das Backup-System schützt Simulationsdateien vor ungewolltem Überschreiben.
### Wann wird ein Backup erstellt?
Ein Backup wird **unmittelbar vor** dem Überschreiben einer Datei erstellt, also beim Synchronisieren einer „Geändert"-, „Erstellt"- oder „Gelöscht"-Änderung, wenn die Datei bereits im Simulations-Ordner existiert.
### Backup-Dateiname
Das Namensschema: `{Originalname}_{JJJJMMTT_HHMMSS}.bak`
Beispiele:
```
Roboter_Arm.jt_20260315_143022.bak
Grundplatte.cojt_20260316_091500.bak
```
### Backup-Speicherort
| Modus | Speicherort |
|---|---|
| Gleicher Ordner | Backup liegt neben der Simulationsdatei |
| Eigener Ordner | Alle Backups in einem zentralen Verzeichnis (Unterordnerstruktur bleibt erhalten) |
### Aufbewahrungsregel
Bei aktivierter Aufbewahrungsregel (z. B. max. 5 Backups pro Datei) wird **nach** dem Erstellen des neuen Backups geprüft, ob ältere Backups gelöscht werden müssen. Die Backups werden nach Erstellungsdatum sortiert die ältesten werden zuerst gelöscht.
### Backup manuell wiederherstellen
EngineeringSync bietet keine automatische Wiederherstellungsfunktion. Um eine Backup-Datei manuell wiederherzustellen:
1. Navigieren Sie zum Backup-Speicherort im Windows Explorer
2. Benennen Sie die `.bak`-Datei um (entfernen Sie den Zeitstempel und `.bak`)
3. Kopieren Sie die Datei an den gewünschten Ort
---
## 8. Windows-Dienst verwalten
Der **EngineeringSync-Dienst** läuft als Windows-Dienst und ist unabhängig von der Tray-App.
### Dienst-Status prüfen
Öffnen Sie den Windows Taskmanager (Strg+Umsch+Esc) → Reiter **„Dienste"**, oder:
```powershell
# PowerShell
Get-Service EngineeringSync
# Ausgabe:
Status Name DisplayName
------ ---- -----------
Running EngineeringSync EngineeringSync
```
### Dienst manuell steuern
```powershell
# Dienst starten
Start-Service EngineeringSync
# Dienst stoppen
Stop-Service EngineeringSync
# Dienst neu starten
Restart-Service EngineeringSync
```
Alternativ über die **Windows Dienste-Verwaltung** (Win+R → `services.msc`).
### API-Endpunkt
Der Dienst stellt eine lokale REST-API unter `http://localhost:5050` bereit. Die Tray-App kommuniziert über diese API. Sie können die API auch direkt im Browser testen:
| Endpunkt | Beschreibung |
|---|---|
| `GET /api/projects` | Alle konfigurierten Projekte |
| `GET /api/changes/{projectId}` | Ausstehende Änderungen eines Projekts |
| `GET /api/changes/{projectId}/history` | Letzte 100 bearbeitete Änderungen |
> **Hinweis:** Der API-Endpunkt ist nur lokal erreichbar (`localhost`). Er ist nicht durch Authentifizierung gesichert stellen Sie sicher, dass kein unberechtigter Netzwerkzugang besteht.
---
## 9. Deinstallation
### Über die Windows Einstellungen
1. Öffnen Sie **Einstellungen → Apps → Installierte Apps**
2. Suchen Sie nach „EngineeringSync"
3. Klicken Sie auf **„Deinstallieren"**
### Manuell
Führen Sie die Setup-Datei mit dem Parameter `/uninstall` aus:
```cmd
EngineeringSync-Setup.exe /uninstall
```
### Was wird bei der Deinstallation entfernt?
| Element | Wird entfernt? |
|---|---|
| Installationsdateien | ✅ Ja |
| Windows-Dienst | ✅ Ja (wird gestoppt und gelöscht) |
| Autostart-Eintrag | ✅ Ja (nur der EngineeringSync-Eintrag) |
| Desktop-Verknüpfung | ✅ Ja |
| Startmenü-Eintrag | ✅ Ja |
| SQLite-Datenbank | ❌ Nein (Konfiguration und Historie bleiben erhalten) |
| Backup-Dateien | ❌ Nein |
| Engineering-/Simulations-Ordner | ❌ Nein (niemals) |
> Die SQLite-Datenbank (`engineeringsync.db`) verbleibt im Installationsordner und kann manuell gelöscht werden.
---
## 10. Technische Architektur
Dieser Abschnitt richtet sich an technische Nutzer und Administratoren.
### Komponenten
```
┌─────────────────────────────────────────────────────────────┐
│ EngineeringSync │
│ │
│ ┌──────────────────┐ ┌───────────────────────────┐ │
│ │ TrayApp (WPF) │◄────│ Service (Worker Service) │ │
│ │ │ │ │ │
│ │ • Tray-Icon │ │ • FileSystemWatcher │ │
│ │ • Änderungs- │ │ • Debounce-Channel │ │
│ │ fenster │ │ • SHA-256-Hashing │ │
│ │ • Projekt- │ │ • SyncManager │ │
│ │ verwaltung │ │ • REST API (Port 5050) │ │
│ │ │ │ • SignalR Hub │ │
│ └────────┬─────────┘ └──────────────┬────────────┘ │
│ │ │ │
│ │ HTTP (REST + SignalR) │ │
│ └───────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ SQLite-Datenbank (WAL-Modus) │ │
│ │ • ProjectConfig • FileRevision • PendingChange │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Datenbankschema
**ProjectConfig** Projektkonfigurationen
| Feld | Typ | Beschreibung |
|---|---|---|
| `Id` | GUID | Eindeutiger Bezeichner |
| `Name` | Text | Anzeigename des Projekts |
| `EngineeringPath` | Text | Überwachter Quellordner |
| `SimulationPath` | Text | Sync-Zielordner |
| `FileExtensions` | Text | Überwachte Dateitypen (komma-getrennt oder `*`) |
| `IsActive` | Bool | Überwachung aktiv/inaktiv |
| `BackupEnabled` | Bool | Backup aktiviert |
| `BackupPath` | Text? | Optionaler Backup-Pfad |
| `MaxBackupsPerFile` | Int | Max. Backups pro Datei (0 = unbegrenzt) |
**FileRevision** Zuletzt bekannte Dateiversion
| Feld | Typ | Beschreibung |
|---|---|---|
| `Id` | GUID | Eindeutiger Bezeichner |
| `ProjectId` | GUID | Verweis auf Projekt |
| `RelativePath` | Text | Relativer Dateipfad |
| `FileHash` | Text | SHA-256-Hash des Dateiinhalts |
| `Size` | Long | Dateigröße in Bytes |
| `LastModified` | DateTime | Letzte Änderungszeit |
**PendingChange** Ausstehende oder bearbeitete Änderungen
| Feld | Typ | Beschreibung |
|---|---|---|
| `Id` | GUID | Eindeutiger Bezeichner |
| `ProjectId` | GUID | Verweis auf Projekt |
| `RelativePath` | Text | Relativer Dateipfad |
| `ChangeType` | Enum | Created / Modified / Renamed / Deleted |
| `OldRelativePath` | Text? | Alter Pfad (nur bei Renamed) |
| `Status` | Enum | Pending / Synced / Ignored |
| `CreatedAt` | DateTime | Zeitpunkt der Erkennung |
| `SyncedAt` | DateTime? | Zeitpunkt der Bearbeitung |
### Kommunikation (SignalR)
Die Tray-App empfängt Echtzeit-Benachrichtigungen über SignalR (WebSockets):
| Methode | Parameter | Auslöser |
|---|---|---|
| `ReceiveChangeNotification` | `projectId, projectName, count` | Neue ausstehende Änderungen erkannt |
| `ProjectConfigChanged` | | Projekt wurde erstellt, geändert oder gelöscht |
### Datenbankdatei
```
%ProgramFiles%\EngineeringSync\engineeringsync.db
```
Die Datenbank läuft im **WAL-Modus** (Write-Ahead Logging), was gleichzeitige Lese- und Schreibzugriffe ohne Sperren ermöglicht.
---
## 11. Fehlerbehebung & FAQ
### Die Tray-App zeigt „Dienst nicht erreichbar"
**Ursache:** Der EngineeringSync-Dienst läuft nicht oder Port 5050 ist belegt.
**Lösung:**
1. Prüfen Sie den Dienst-Status: `Get-Service EngineeringSync`
2. Starten Sie den Dienst: `Start-Service EngineeringSync`
3. Falls Port 5050 belegt ist: `netstat -ano | findstr :5050`
---
### Änderungen werden nicht erkannt
**Mögliche Ursachen und Lösungen:**
| Ursache | Lösung |
|---|---|
| Projekt ist inaktiv | In Projektverwaltung das Projekt aktivieren |
| Falsche Dateierweiterung konfiguriert | Erweiterungen prüfen oder „Alle Dateitypen" wählen |
| Engineering-Pfad nicht erreichbar | Pfad im Explorer prüfen, Netzlaufwerk verbunden? |
| Dienst gestoppt | `Start-Service EngineeringSync` |
---
### Sehr viele Änderungen auf einmal (z. B. nach Bulk-Copy)
Bei vielen gleichzeitigen Dateiereignissen kann der interne Event-Puffer des Datei-Watchers überlaufen. EngineeringSync startet in diesem Fall automatisch einen vollständigen Re-Scan des Engineering-Ordners. Dieser Vorgang kann bei großen Ordnern mehrere Minuten dauern.
---
### Eine Datei ist gesperrt und wird nicht synchronisiert
CAD-Anwendungen sperren Dateien während der Bearbeitung exklusiv. EngineeringSync versucht beim Erkennen einer gesperrten Datei automatisch bis zu **3 Wiederholungen** mit zunehmendem Warteintervall (2s, 4s, 6s). Wenn die Datei nach allen Versuchen noch gesperrt ist, wird das Ereignis in den Logs vermerkt und beim nächsten periodischen Scan erneut versucht.
**Empfehlung:** Synchronisieren Sie Dateien erst, wenn die CAD-Anwendung die Datei freigegeben hat (Datei geschlossen oder gespeichert).
---
### Wo liegen die Log-Dateien?
```
%ProgramFiles%\EngineeringSync\logs\
```
Oder im Windows Ereignisprotokoll unter:
**Ereignisanzeige → Windows-Protokolle → Anwendung** (Quelle: `EngineeringSync`)
---
### Kann ich mehrere Engineering-Ordner mit einem Simulations-Ordner verbinden?
Ja legen Sie pro Engineering-Ordner ein eigenes Projekt an. Mehrere Projekte können auf denselben Simulations-Ordner zeigen, solange sich die relativen Pfade nicht überschneiden.
---
### Was passiert, wenn ich eine Datei im Simulations-Ordner manuell bearbeite?
EngineeringSync überwacht **nur** den Engineering-Quellordner. Änderungen im Simulations-Ordner werden nicht erfasst. Bei der nächsten Synchronisation einer entsprechenden Datei wird die manuell bearbeitete Version im Simulations-Ordner durch ein Backup gesichert und dann überschrieben.
---
### Wie hoch ist der Ressourcenverbrauch des Dienstes?
Im Normalbetrieb (Überwachung aktiv, keine Aktivität):
- CPU: < 0,1 %
- RAM: ca. 5080 MB
Bei einem vollständigen Re-Scan großer Ordner (>10.000 Dateien) kann die CPU-Last kurzzeitig auf 1020 % steigen.
---
### Kann ich die Datenbank auf einem Netzlaufwerk ablegen?
Nein. Die SQLite-Datenbank muss auf einem lokalen Laufwerk liegen. SQLite und Netzlaufwerke sind nicht kompatibel (Sperr-Mechanismus funktioniert über SMB nicht zuverlässig).
---
*Dieses Handbuch bezieht sich auf EngineeringSync Version 1.0.0.*
*Für Fragen und Support wenden Sie sich an den Projektverantwortlichen.*

View File

@@ -0,0 +1,967 @@
# Backup-Einstellungen pro Projekt Implementierungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Pro-Projekt konfigurierbare Backup-Einstellungen (an/aus, eigener Ordner, Aufbewahrungsregel) im Setup-Wizard und TrayApp.
**Architecture:** Drei neue Felder direkt in `ProjectConfig` (BackupEnabled, BackupPath, MaxBackupsPerFile). `SyncManager.BackupFile` wird zur Instanzmethode mit cross-volume-sicherem Verschieben und automatischem Cleanup. Setup-Wizard erhält eine neue `BackupOptionsPage`, TrayApp eine neue BACKUP-Sektion im `ProjectManagementWindow`.
**Tech Stack:** .NET 10, EF Core 10 + SQLite, ASP.NET Core Minimal API, WPF + CommunityToolkit.Mvvm, `OpenFolderDialog` (Microsoft.Win32)
---
## Dateiübersicht
| Aktion | Datei |
|--------|-------|
| Modify | `EngineeringSync.Domain/Entities/ProjectConfig.cs` |
| Modify | `EngineeringSync.Infrastructure/AppDbContext.cs` |
| **Generate** | `EngineeringSync.Infrastructure/Migrations/..._AddBackupSettings.cs` (via `dotnet ef`) |
| Modify | `EngineeringSync.Service/Services/SyncManager.cs` |
| Modify | `EngineeringSync.Service/Models/ApiModels.cs` |
| Modify | `EngineeringSync.Service/Api/ProjectsApi.cs` |
| Modify | `EngineeringSync.Service/Program.cs` |
| Modify | `EngineeringSync.Setup/ViewModels/WizardState.cs` |
| **Create** | `EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml` |
| **Create** | `EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml.cs` |
| Modify | `EngineeringSync.Setup/ViewModels/WizardViewModel.cs` |
| Modify | `EngineeringSync.Setup/Services/InstallerService.cs` |
| Modify | `EngineeringSync.Setup/Views/Pages/SummaryPage.xaml` |
| Modify | `EngineeringSync.TrayApp/Services/ApiClient.cs` |
| Modify | `EngineeringSync.TrayApp/ViewModels/ProjectManagementViewModel.cs` |
| Modify | `EngineeringSync.TrayApp/Views/ProjectManagementWindow.xaml` |
| Modify | `EngineeringSync.TrayApp/Views/ProjectManagementWindow.xaml.cs` |
---
## Task 1: Domain Backup-Felder in ProjectConfig
**Files:**
- Modify: `EngineeringSync.Domain/Entities/ProjectConfig.cs`
- [ ] **Schritt 1: Drei Felder nach `IsActive` einfügen**
```csharp
// In ProjectConfig.cs nach "public bool IsActive" einfügen:
public bool BackupEnabled { get; set; } = true;
public string? BackupPath { get; set; } = null; // null = gleicher Ordner
public int MaxBackupsPerFile { get; set; } = 0; // 0 = unbegrenzt
```
- [ ] **Schritt 2: Build prüfen**
```bash
dotnet build EngineeringSync.Domain/EngineeringSync.Domain.csproj
```
Erwartung: `Build succeeded` ohne Fehler.
- [ ] **Schritt 3: Commit**
```bash
git add EngineeringSync.Domain/Entities/ProjectConfig.cs
git commit -m "feat(domain): add BackupEnabled, BackupPath, MaxBackupsPerFile to ProjectConfig"
```
---
## Task 2: Infrastructure EF-Migration
**Files:**
- Modify: `EngineeringSync.Infrastructure/AppDbContext.cs`
- Generate: `EngineeringSync.Infrastructure/Migrations/..._AddBackupSettings.cs`
- [ ] **Schritt 1: HasDefaultValue in AppDbContext eintragen**
In `AppDbContext.cs`, die bestehende `modelBuilder.Entity<ProjectConfig>(e => {...})` Konfiguration erweitern:
```csharp
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();
// NEU: Standardwerte für Backup-Felder (für bestehende Datenbankzeilen)
e.Property(p => p.BackupEnabled).HasDefaultValue(true);
e.Property(p => p.BackupPath).HasDefaultValue(null);
e.Property(p => p.MaxBackupsPerFile).HasDefaultValue(0);
});
```
- [ ] **Schritt 2: Migration generieren**
```bash
cd D:/001_Projekte/021_KON-SIM
dotnet ef migrations add AddBackupSettingsToProjectConfig \
--project EngineeringSync.Infrastructure \
--startup-project EngineeringSync.Service
```
Erwartung: Neue Migrationsdatei in `EngineeringSync.Infrastructure/Migrations/` mit drei `AddColumn`-Aufrufen für `BackupEnabled`, `BackupPath`, `MaxBackupsPerFile`.
- [ ] **Schritt 3: Generierte Migration kurz prüfen**
Die generierte `.cs`-Datei öffnen und sicherstellen, dass `defaultValue: true` (BackupEnabled) und `defaultValue: 0` (MaxBackupsPerFile) enthalten sind. Falls Werte fehlen: manuell ergänzen.
- [ ] **Schritt 4: Build prüfen**
```bash
dotnet build EngineeringSync.Infrastructure/EngineeringSync.Infrastructure.csproj
```
Erwartung: `Build succeeded`.
- [ ] **Schritt 5: Commit**
```bash
git add EngineeringSync.Infrastructure/AppDbContext.cs
git add EngineeringSync.Infrastructure/Migrations/
git commit -m "feat(infra): migration AddBackupSettingsToProjectConfig"
```
---
## Task 3: Service SyncManager refaktorieren
**Files:**
- Modify: `EngineeringSync.Service/Services/SyncManager.cs`
- [ ] **Schritt 1: `ProcessChangeAsync` `static` entfernen und beide BackupFile-Aufrufe anpassen**
Die Methode `ProcessChangeAsync` ist aktuell `static` (Zeile 47). Da sie gleich eine Instanzmethode (`BackupFile`) aufrufen soll, muss `static` entfernt werden:
```csharp
// Vorher:
private static async Task ProcessChangeAsync(PendingChange change, CancellationToken ct)
// Nachher:
private async Task ProcessChangeAsync(PendingChange change, CancellationToken ct)
```
Dann beide BackupFile-Aufrufe anpassen:
- Zeile ~57: im `Deleted`-Branch: `BackupFile(targetPath);``BackupFile(targetPath, project);`
- Zeile ~69: im Overwrite-Branch: `BackupFile(targetPath);``BackupFile(targetPath, project);`
- [ ] **Schritt 2: `BackupFile` von `static` zur Instanzmethode umbauen**
```csharp
private void BackupFile(string targetPath, ProjectConfig project)
{
if (!project.BackupEnabled)
return;
var backupDir = project.BackupPath ?? Path.GetDirectoryName(targetPath)!;
Directory.CreateDirectory(backupDir);
var nameWithoutExt = Path.GetFileNameWithoutExtension(targetPath);
var ext = Path.GetExtension(targetPath);
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var backupPath = Path.Combine(backupDir, $"{nameWithoutExt}_{timestamp}{ext}.bak");
// Cross-volume-sicheres Verschieben (z.B. Netzlaufwerke, andere Laufwerksbuchstaben)
try
{
File.Move(targetPath, backupPath);
}
catch (IOException)
{
File.Copy(targetPath, backupPath, overwrite: false);
File.Delete(targetPath);
}
if (project.MaxBackupsPerFile > 0)
CleanupOldBackups(backupDir, nameWithoutExt, ext, project.MaxBackupsPerFile);
}
```
- [ ] **Schritt 3: `CleanupOldBackups` als private Instanzmethode hinzufügen**
```csharp
private void CleanupOldBackups(string backupDir, string baseName, string ext, int max)
{
try
{
var pattern = $"{baseName}_*{ext}.bak";
var backups = Directory.GetFiles(backupDir, pattern)
.OrderBy(f => Path.GetFileName(f)) // yyyyMMdd_HHmmss → lexikografisch = zeitlich
.ToList();
while (backups.Count > max)
{
File.Delete(backups[0]);
backups.RemoveAt(0);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Cleanup alter Backups fehlgeschlagen in {Dir}", backupDir);
}
}
```
- [ ] **Schritt 4: Build prüfen**
```bash
dotnet build EngineeringSync.Service/EngineeringSync.Service.csproj
```
Erwartung: `Build succeeded`. Bei Compiler-Fehler `static member cannot be used with instance receiver`: sicherstellen dass `BackupFile` nicht mehr `static` ist.
- [ ] **Schritt 5: Commit**
```bash
git add EngineeringSync.Service/Services/SyncManager.cs
git commit -m "feat(service): BackupFile als Instanzmethode mit cross-volume-Support und CleanupOldBackups"
```
---
## Task 4: Service API-Modelle und ProjectsApi
**Files:**
- Modify: `EngineeringSync.Service/Models/ApiModels.cs`
- Modify: `EngineeringSync.Service/Api/ProjectsApi.cs`
- [ ] **Schritt 1: API-Modelle um Backup-Felder erweitern**
`CreateProjectRequest` und `UpdateProjectRequest` in `ApiModels.cs` ersetzen durch:
```csharp
public record CreateProjectRequest(
string Name,
string EngineeringPath,
string SimulationPath,
string FileExtensions,
bool IsActive = true,
bool BackupEnabled = true,
string? BackupPath = null,
int MaxBackupsPerFile = 0
);
public record UpdateProjectRequest(
string Name,
string EngineeringPath,
string SimulationPath,
string FileExtensions,
bool IsActive,
bool BackupEnabled = true,
string? BackupPath = null,
int MaxBackupsPerFile = 0
);
```
- [ ] **Schritt 2: ProjectsApi neue Felder beim Erstellen mappen**
In `ProjectsApi.cs`, den `MapPost`-Handler: beim Erstellen der `ProjectConfig` die neuen Felder setzen:
```csharp
var project = new ProjectConfig
{
Name = req.Name,
EngineeringPath = req.EngineeringPath,
SimulationPath = req.SimulationPath,
FileExtensions = req.FileExtensions,
IsActive = req.IsActive,
BackupEnabled = req.BackupEnabled, // NEU
BackupPath = req.BackupPath, // NEU
MaxBackupsPerFile = req.MaxBackupsPerFile // NEU
};
```
- [ ] **Schritt 3: ProjectsApi neue Felder beim Aktualisieren mappen**
Im `MapPut`-Handler, nach den bestehenden Zuweisungen ergänzen:
```csharp
project.Name = req.Name;
project.EngineeringPath = req.EngineeringPath;
project.SimulationPath = req.SimulationPath;
project.FileExtensions = req.FileExtensions;
project.IsActive = req.IsActive;
project.BackupEnabled = req.BackupEnabled; // NEU
project.BackupPath = req.BackupPath; // NEU
project.MaxBackupsPerFile = req.MaxBackupsPerFile; // NEU
```
- [ ] **Schritt 4: Build prüfen**
```bash
dotnet build EngineeringSync.Service/EngineeringSync.Service.csproj
```
Erwartung: `Build succeeded`.
- [ ] **Schritt 5: Commit**
```bash
git add EngineeringSync.Service/Models/ApiModels.cs EngineeringSync.Service/Api/ProjectsApi.cs
git commit -m "feat(service): Backup-Felder in API-Modelle und ProjectsApi"
```
---
## Task 5: Service Program.cs ProcessFirstRunConfigAsync
**Files:**
- Modify: `EngineeringSync.Service/Program.cs`
- [ ] **Schritt 1: Backup-Abschnitt aus firstrun-config.json lesen**
In `Program.cs`, in `ProcessFirstRunConfigAsync()`, direkt nach dem Auslesen von `watchAllFiles` (ca. Zeile 58) folgenden Block einfügen:
```csharp
// Backup-Einstellungen (optional, mit Standardwerten für Rückwärtskompatibilität)
var backupEnabled = true;
string? backupPath = null;
var maxBackupsPerFile = 0;
if (root.RootElement.TryGetProperty("Backup", out var backupElement))
{
if (backupElement.TryGetProperty("BackupEnabled", out var be))
backupEnabled = be.GetBoolean();
if (backupElement.TryGetProperty("BackupPath", out var bp) &&
bp.ValueKind != JsonValueKind.Null)
backupPath = bp.GetString();
if (backupElement.TryGetProperty("MaxBackupsPerFile", out var mb))
maxBackupsPerFile = mb.GetInt32();
}
```
- [ ] **Schritt 2: Backup-Felder beim Erstellen der ProjectConfig setzen**
Im selben `ProcessFirstRunConfigAsync`, beim `new ProjectConfig { ... }` Block die neuen Felder ergänzen:
```csharp
var project = new EngineeringSync.Domain.Entities.ProjectConfig
{
Name = projectName,
EngineeringPath = engineeringPath,
SimulationPath = simulationPath,
FileExtensions = fileExtensions,
IsActive = true,
CreatedAt = DateTime.UtcNow,
BackupEnabled = backupEnabled, // NEU
BackupPath = backupPath, // NEU
MaxBackupsPerFile = maxBackupsPerFile // NEU
};
```
- [ ] **Schritt 3: Build prüfen**
```bash
dotnet build EngineeringSync.Service/EngineeringSync.Service.csproj
```
Erwartung: `Build succeeded`.
- [ ] **Schritt 4: Commit**
```bash
git add EngineeringSync.Service/Program.cs
git commit -m "feat(service): Backup-Felder aus firstrun-config.json lesen"
```
---
## Task 6: Setup-Wizard WizardState
**Files:**
- Modify: `EngineeringSync.Setup/ViewModels/WizardState.cs`
- [ ] **Schritt 1: Vier neue Properties im Abschnitt „Erstes Projekt" hinzufügen**
```csharp
// --- Backup-Einstellungen ---
[ObservableProperty] private bool _backupEnabled = true;
[ObservableProperty] private bool _backupUseCustomPath = false;
[ObservableProperty] private string _backupCustomPath = string.Empty;
[ObservableProperty] private int _maxBackupsPerFile = 0;
```
- [ ] **Schritt 2: Build prüfen**
```bash
dotnet build EngineeringSync.Setup/EngineeringSync.Setup.csproj
```
- [ ] **Schritt 3: Commit**
```bash
git add EngineeringSync.Setup/ViewModels/WizardState.cs
git commit -m "feat(setup): Backup-Properties in WizardState"
```
---
## Task 7: Setup-Wizard BackupOptionsPage erstellen
**Files:**
- Create: `EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml`
- Create: `EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml.cs`
- [ ] **Schritt 1: BackupOptionsPage.xaml erstellen**
```xml
<local:WizardPageBase x:Class="EngineeringSync.Setup.Views.Pages.BackupOptionsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:EngineeringSync.Setup.Views.Pages"
Background="White">
<Grid Margin="40,32,40,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Backup-Einstellungen"
Style="{StaticResource PageTitleStyle}"/>
<TextBlock Grid.Row="1" Style="{StaticResource PageSubtitleStyle}"
Text="Legen Sie fest, ob und wie Sicherungskopien vor dem Überschreiben von Simulationsdateien erstellt werden."/>
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Master-Toggle -->
<local:OptionCard Icon="&#xE72E;" Title="Backups aktivieren"
Description="Vor jeder Dateiüberschreibung wird automatisch eine .bak-Sicherungskopie erstellt"
IsChecked="{Binding BackupEnabled, Mode=TwoWay}"/>
<!-- Optionen (nur wenn Backup aktiviert) -->
<StackPanel Visibility="{Binding BackupEnabled,
Converter={StaticResource BoolToVisConverter}}">
<!-- Speicherort -->
<TextBlock Text="SPEICHERORT" Style="{StaticResource FieldLabelStyle}"
Margin="0,16,0,8"/>
<!-- Mode=OneWay: gegenseitiger Ausschluss läuft über GroupName + TwoWay am zweiten Radio -->
<RadioButton GroupName="BackupLocation"
Content="Gleicher Ordner wie die Simulationsdatei"
IsChecked="{Binding BackupUseCustomPath,
Converter={StaticResource BoolToInvVisConverter},
Mode=OneWay}"
FontFamily="Segoe UI" FontSize="13" Margin="0,0,0,8"/>
<RadioButton GroupName="BackupLocation"
Content="Eigener Backup-Ordner"
IsChecked="{Binding BackupUseCustomPath, Mode=TwoWay}"
FontFamily="Segoe UI" FontSize="13" Margin="0,0,0,8"/>
<!-- Pfad-Eingabe (nur bei eigenem Ordner) -->
<DockPanel Margin="0,0,0,16"
Visibility="{Binding BackupUseCustomPath,
Converter={StaticResource BoolToVisConverter}}">
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}"
Margin="6,0,0,0" Click="BrowseBackupPath_Click"
ToolTip="Backup-Verzeichnis wählen">
<TextBlock Text="&#xED25;" FontFamily="Segoe MDL2 Assets"
FontSize="14" Foreground="#0078D4"/>
</Button>
<TextBox Style="{StaticResource ModernTextBoxStyle}"
Text="{Binding BackupCustomPath, UpdateSourceTrigger=PropertyChanged}"
Height="36"/>
</DockPanel>
<!-- Aufbewahrung -->
<TextBlock Text="AUFBEWAHRUNG" Style="{StaticResource FieldLabelStyle}"
Margin="0,0,0,8"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Maximal" FontFamily="Segoe UI" FontSize="13"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox x:Name="MaxBackupsBox"
Width="60" Height="32" Padding="6,0"
FontFamily="Segoe UI" FontSize="13"
Text="{Binding MaxBackupsPerFile, UpdateSourceTrigger=PropertyChanged}"
VerticalContentAlignment="Center"/>
<TextBlock Text="Backups pro Datei" FontFamily="Segoe UI" FontSize="13"
VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<TextBlock Text="(0 = unbegrenzt, alle Backups behalten)"
FontFamily="Segoe UI" FontSize="11" Foreground="#5F5F5F"
Margin="0,2,0,0"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</local:WizardPageBase>
```
- [ ] **Schritt 2: BackupOptionsPage.xaml.cs erstellen**
```csharp
using System.Windows;
using EngineeringSync.Setup.ViewModels;
using Microsoft.Win32;
namespace EngineeringSync.Setup.Views.Pages;
public partial class BackupOptionsPage : WizardPageBase
{
public BackupOptionsPage(WizardViewModel wizard) : base(wizard)
{
InitializeComponent();
}
private void BrowseBackupPath_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog
{
Title = "Backup-Verzeichnis wählen",
InitialDirectory = string.IsNullOrEmpty(Wizard.State.BackupCustomPath)
? null
: Wizard.State.BackupCustomPath
};
if (dlg.ShowDialog() == true)
Wizard.State.BackupCustomPath = dlg.FolderName;
}
public override bool Validate()
{
if (Wizard.State.BackupEnabled &&
Wizard.State.BackupUseCustomPath &&
string.IsNullOrWhiteSpace(Wizard.State.BackupCustomPath))
{
MessageBox.Show("Bitte wählen Sie einen Backup-Ordner oder deaktivieren Sie die Option 'Eigener Backup-Ordner'.",
"Validierung", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
return true;
}
}
```
- [ ] **Schritt 3: Build prüfen**
```bash
dotnet build EngineeringSync.Setup/EngineeringSync.Setup.csproj
```
Erwartung: `Build succeeded`. Häufiger Fehler: fehlendes `x:Class`-Attribut oder falsche Namespace-Angaben → prüfen ob `xmlns:local` und `x:Class` übereinstimmen.
- [ ] **Schritt 4: Commit**
```bash
git add EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml
git add EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml.cs
git commit -m "feat(setup): BackupOptionsPage erstellen"
```
---
## Task 8: Setup-Wizard WizardViewModel und InstallerService
**Files:**
- Modify: `EngineeringSync.Setup/ViewModels/WizardViewModel.cs`
- Modify: `EngineeringSync.Setup/Services/InstallerService.cs`
- Modify: `EngineeringSync.Setup/Views/Pages/SummaryPage.xaml`
- [ ] **Schritt 1: WizardViewModel neuen Schritt und neue Page einfügen**
In `WizardViewModel.cs`, die `Steps`-Liste: neuen Eintrag an Index 3 einfügen:
```csharp
public ObservableCollection<WizardStep> Steps { get; } =
[
new("Willkommen", "\uE80F"),
new("Installation", "\uE7B7"),
new("Erstes Projekt", "\uE8B7"),
new("Backup", "\uE72E"), // NEU Index 3
new("Optionen", "\uE713"),
new("Zusammenfassung","\uE8A9"),
new("Installation", "\uE896"),
new("Fertig", "\uE930"),
];
```
Und `_pageFactories`: `() => new BackupOptionsPage(this)` an Index 3 einfügen:
```csharp
_pageFactories =
[
() => new WelcomePage(this),
() => new InstallPathPage(this),
() => new FirstProjectPage(this),
() => new BackupOptionsPage(this), // NEU Index 3
() => new ServiceOptionsPage(this),
() => new SummaryPage(this),
() => new InstallingPage(this, _installer),
() => new CompletionPage(this),
];
```
- [ ] **Schritt 2: InstallerService Backup-Abschnitt in firstrun-config.json schreiben**
In `InstallerService.cs`, in der Methode `WriteFirstRunConfig()`:
Das anonyme Objekt `config` erweitern um einen `Backup`-Abschnitt:
```csharp
var config = new
{
FirstRun = new
{
ProjectName = state.ProjectName,
EngineeringPath = state.EngineeringPath,
SimulationPath = state.SimulationPath,
FileExtensions = state.WatchAllFiles ? "*" : state.FileExtensions,
WatchAllFiles = state.WatchAllFiles
},
Backup = new // NEU
{
BackupEnabled = state.BackupEnabled,
BackupPath = state.BackupUseCustomPath ? state.BackupCustomPath : (string?)null,
MaxBackupsPerFile = state.MaxBackupsPerFile
}
};
```
- [ ] **Schritt 3: SummaryPage.xaml Backup-Karte hinzufügen**
In `SummaryPage.xaml`, nach dem bestehenden „Optionen"-Block, vor dem abschließenden `</StackPanel>` des `ScrollViewer`, folgende Karte einfügen:
```xml
<!-- Backup -->
<Border Style="{StaticResource InfoCardStyle}">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="&#xE72E;" FontFamily="Segoe MDL2 Assets" FontSize="14"
Foreground="#0078D4" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Text="Backup" FontFamily="Segoe UI" FontSize="13"
FontWeight="SemiBold" Foreground="#1A1A1A" VerticalAlignment="Center"/>
</StackPanel>
<local:SummaryBoolRow Label="Backups aktiviert" Value="{Binding BackupEnabled}"/>
<local:SummaryRow Label="Backup-Ordner"
Value="{Binding BackupCustomPath}"
Visibility="{Binding BackupUseCustomPath,
Converter={StaticResource BoolToVisConverter}}"/>
<!-- Aufbewahrung immer anzeigen wenn Backup aktiv (0 = unbegrenzt ist ein valider Wert) -->
<local:SummaryRow Label="Max. Backups/Datei"
Value="{Binding MaxBackupsPerFile,
StringFormat={}{0} (0&#x3D;unbegrenzt)}"
Visibility="{Binding BackupEnabled,
Converter={StaticResource BoolToVisConverter}}"/>
</StackPanel>
</Border>
```
- [ ] **Schritt 4: Build prüfen**
```bash
dotnet build EngineeringSync.Setup/EngineeringSync.Setup.csproj
```
- [ ] **Schritt 5: Commit**
```bash
git add EngineeringSync.Setup/ViewModels/WizardViewModel.cs
git add EngineeringSync.Setup/Services/InstallerService.cs
git add EngineeringSync.Setup/Views/Pages/SummaryPage.xaml
git commit -m "feat(setup): BackupOptionsPage in Wizard einbinden, InstallerService und SummaryPage"
```
---
## Task 9: TrayApp ApiClient-DTOs
**Files:**
- Modify: `EngineeringSync.TrayApp/Services/ApiClient.cs`
- [ ] **Schritt 1: DTOs um Backup-Felder erweitern**
`CreateProjectDto` und `UpdateProjectDto` in `ApiClient.cs` ersetzen:
```csharp
public record CreateProjectDto(
string Name,
string EngineeringPath,
string SimulationPath,
string FileExtensions,
bool IsActive = true,
bool BackupEnabled = true,
string? BackupPath = null,
int MaxBackupsPerFile = 0
);
public record UpdateProjectDto(
string Name,
string EngineeringPath,
string SimulationPath,
string FileExtensions,
bool IsActive,
bool BackupEnabled = true,
string? BackupPath = null,
int MaxBackupsPerFile = 0
);
```
- [ ] **Schritt 2: Build prüfen**
```bash
dotnet build EngineeringSync.TrayApp/EngineeringSync.TrayApp.csproj
```
- [ ] **Schritt 3: Commit**
```bash
git add EngineeringSync.TrayApp/Services/ApiClient.cs
git commit -m "feat(trayapp): Backup-Felder in CreateProjectDto und UpdateProjectDto"
```
---
## Task 10: TrayApp ProjectManagementViewModel
**Files:**
- Modify: `EngineeringSync.TrayApp/ViewModels/ProjectManagementViewModel.cs`
- [ ] **Schritt 1: Vier neue ObservableProperty-Felder hinzufügen**
Nach `_editIsActive` einfügen:
```csharp
[ObservableProperty] private bool _editBackupEnabled = true;
[ObservableProperty] private bool _editBackupUseCustomPath = false;
[ObservableProperty] private string _editBackupCustomPath = string.Empty;
[ObservableProperty] private int _editMaxBackupsPerFile = 0;
```
- [ ] **Schritt 2: `StartNewProject()` Standardwerte setzen**
Nach `EditIsActive = true;` einfügen:
```csharp
EditBackupEnabled = true;
EditBackupUseCustomPath = false;
EditBackupCustomPath = string.Empty;
EditMaxBackupsPerFile = 0;
```
- [ ] **Schritt 3: `EditProject()` Werte aus ProjectConfig laden**
Nach `EditIsActive = project.IsActive;` einfügen:
```csharp
EditBackupEnabled = project.BackupEnabled;
EditBackupUseCustomPath = project.BackupPath is not null;
EditBackupCustomPath = project.BackupPath ?? string.Empty;
EditMaxBackupsPerFile = project.MaxBackupsPerFile;
```
- [ ] **Schritt 4: `SaveAsync()` Backup-Felder in DTOs mitsenden**
In `SaveAsync()`, die beiden DTO-Konstruktoraufrufe ersetzen:
```csharp
// CreateProjectDto:
await api.CreateProjectAsync(new CreateProjectDto(
EditName, EditEngineeringPath, EditSimulationPath, EditFileExtensions, EditIsActive,
EditBackupEnabled,
EditBackupUseCustomPath ? EditBackupCustomPath : null,
EditMaxBackupsPerFile));
// UpdateProjectDto:
await api.UpdateProjectAsync(SelectedProject!.Id, new UpdateProjectDto(
EditName, EditEngineeringPath, EditSimulationPath, EditFileExtensions, EditIsActive,
EditBackupEnabled,
EditBackupUseCustomPath ? EditBackupCustomPath : null,
EditMaxBackupsPerFile));
```
- [ ] **Schritt 5: Build prüfen**
```bash
dotnet build EngineeringSync.TrayApp/EngineeringSync.TrayApp.csproj
```
- [ ] **Schritt 6: Commit**
```bash
git add EngineeringSync.TrayApp/ViewModels/ProjectManagementViewModel.cs
git commit -m "feat(trayapp): Backup-Properties in ProjectManagementViewModel"
```
---
## Task 11: TrayApp ProjectManagementWindow XAML + Code-Behind
**Files:**
- Modify: `EngineeringSync.TrayApp/Views/ProjectManagementWindow.xaml`
- Modify: `EngineeringSync.TrayApp/Views/ProjectManagementWindow.xaml.cs`
- [ ] **Schritt 1: BACKUP-Sektion in das Editierformular einfügen**
In `ProjectManagementWindow.xaml`, direkt **vor** der `StatusMessage`-TextBlock-Zeile (ca. Zeile 116) folgende Sektion einfügen:
```xml
<!-- BACKUP-Sektion -->
<Separator Margin="0,8,0,12"/>
<TextBlock Text="BACKUP" FontSize="10" FontWeight="Bold" Foreground="#888888"
LetterSpacing="1" Margin="0,0,0,8"/>
<CheckBox Content="Backups vor dem Überschreiben erstellen"
IsChecked="{Binding EditBackupEnabled}"
Margin="0,0,0,10"/>
<StackPanel Visibility="{Binding EditBackupEnabled,
Converter={StaticResource BoolToVisConverter}}">
<TextBlock Text="Speicherort:" FontWeight="SemiBold" Margin="0,0,0,6"/>
<!-- Mode=OneWay: gegenseitiger Ausschluss läuft über GroupName + TwoWay am zweiten Radio -->
<RadioButton GroupName="BackupLocation" Content="Gleicher Ordner wie Simulationsdatei"
IsChecked="{Binding EditBackupUseCustomPath,
Converter={StaticResource InverseBoolToVisConverter},
Mode=OneWay}"
Margin="0,0,0,4"/>
<RadioButton GroupName="BackupLocation" Content="Eigener Backup-Ordner"
IsChecked="{Binding EditBackupUseCustomPath, Mode=TwoWay}"
Margin="0,0,0,6"/>
<DockPanel Margin="0,0,0,10"
Visibility="{Binding EditBackupUseCustomPath,
Converter={StaticResource BoolToVisConverter}}">
<Button DockPanel.Dock="Right" Content="..." Width="32" Margin="4,0,0,0"
Click="BrowseBackup_Click"/>
<TextBox Text="{Binding EditBackupCustomPath, UpdateSourceTrigger=PropertyChanged}"
Padding="6"/>
</DockPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="Max. Backups pro Datei:" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox Width="50" Padding="4,2"
Text="{Binding EditMaxBackupsPerFile, UpdateSourceTrigger=PropertyChanged}"
VerticalContentAlignment="Center"/>
<TextBlock Text="(0 = unbegrenzt)" FontSize="11" Foreground="Gray"
VerticalAlignment="Center" Margin="6,0,0,0"/>
</StackPanel>
</StackPanel>
```
**Hinweis:** Das `ProjectManagementWindow` hat bereits `BooleanToVisibilityConverter` als `BoolToVisConverter`. Für die inverse Sichtbarkeit der RadioButton-Binding muss ein `InverseBoolToVisibilityConverter` in den Window-Ressourcen ergänzt werden **oder** es wird ein Code-Behind `IsChecked`-Binding auf `!EditBackupUseCustomPath` verwendet (nicht direkt in XAML). Einfachste Lösung: eine neue Converter-Klasse in `TrayApp/Converters/Converters.cs` und in `Window.Resources` registrieren (siehe Schritt 2).
- [ ] **Schritt 2: InverseBoolToVisibilityConverter in TrayApp hinzufügen**
In `EngineeringSync.TrayApp/Converters/Converters.cs` (bestehende Datei) einen neuen Converter hinzufügen:
```csharp
public class InverseBoolToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is true ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
```
Und in `ProjectManagementWindow.xaml`, in `<Window.Resources>` registrieren:
```xml
<conv:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisConverter"/>
```
**Alternative:** Den RadioButton für „Gleicher Ordner" ohne Converter binden: einfach keine Binding der zweite RadioButton (`EditBackupUseCustomPath = true`) und der erste sind in einer Gruppe; mit `GroupName` explizit koppeln und beide direkt binden:
```xml
<RadioButton GroupName="BackupLocation" Content="Gleicher Ordner wie Simulationsdatei"
IsChecked="{Binding EditBackupUseCustomPath,
Converter={StaticResource InverseBoolToVisConverter}, Mode=TwoWay}"/>
<RadioButton GroupName="BackupLocation" Content="Eigener Backup-Ordner"
IsChecked="{Binding EditBackupUseCustomPath, Mode=TwoWay}"/>
```
- [ ] **Schritt 3: Browse-Handler in Code-Behind hinzufügen**
In `ProjectManagementWindow.xaml.cs` (bestehende Datei), nach den vorhandenen Browse-Handlern einfügen:
```csharp
private void BrowseBackup_Click(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog
{
Title = "Backup-Verzeichnis wählen",
InitialDirectory = string.IsNullOrEmpty(_vm.EditBackupCustomPath)
? null
: _vm.EditBackupCustomPath
};
if (dlg.ShowDialog() == true)
_vm.EditBackupCustomPath = dlg.FolderName;
}
```
- [ ] **Schritt 4: Build prüfen**
```bash
dotnet build EngineeringSync.TrayApp/EngineeringSync.TrayApp.csproj
```
Häufige Fehler: fehlender `using System.Globalization;` in Converters.cs, fehlende Namespaces in XAML.
- [ ] **Schritt 5: Commit**
```bash
git add EngineeringSync.TrayApp/Views/ProjectManagementWindow.xaml
git add EngineeringSync.TrayApp/Views/ProjectManagementWindow.xaml.cs
git add EngineeringSync.TrayApp/Converters/Converters.cs
git commit -m "feat(trayapp): BACKUP-Sektion in ProjectManagementWindow"
```
---
## Task 12: Gesamtbuild und manuelle Verifikation
- [ ] **Schritt 1: Gesamtlösung bauen**
```bash
dotnet build EngineeringSync.slnx
```
Erwartung: `Build succeeded` für alle 5 Projekte (Domain, Infrastructure, Service, TrayApp, Setup).
- [ ] **Schritt 2: Service starten und Migration prüfen**
```bash
dotnet run --project EngineeringSync.Service
```
Erwartung: Service startet auf `http://localhost:5050`. In der Konsole erscheint die Migration-Meldung. Die SQLite-DB unter `%ProgramData%\EngineeringSync\engineeringsync.db` hat nun die Spalten `BackupEnabled`, `BackupPath`, `MaxBackupsPerFile` in der `Projects`-Tabelle.
Prüfen mit:
```bash
# SQLite-Datei öffnen (DB Browser for SQLite oder sqlite3 CLI)
# SELECT * FROM Projects LIMIT 1;
# Alle 3 neuen Spalten müssen vorhanden sein.
```
- [ ] **Schritt 3: API-Endpunkt testen**
```bash
# Projekt erstellen mit Backup-Feldern
curl -X POST http://localhost:5050/api/projects \
-H "Content-Type: application/json" \
-d "{\"name\":\"Test\",\"engineeringPath\":\"C:\\\\Temp\\\\eng\",\"simulationPath\":\"C:\\\\Temp\\\\sim\",\"fileExtensions\":\".jt\",\"backupEnabled\":true,\"backupPath\":null,\"maxBackupsPerFile\":3}"
# Antwort muss Id, BackupEnabled=true, MaxBackupsPerFile=3 enthalten
```
- [ ] **Schritt 4: TrayApp starten und Backup-Sektion prüfen**
```bash
dotnet run --project EngineeringSync.TrayApp
```
- Projektverwaltung öffnen → Projekt bearbeiten → BACKUP-Sektion erscheint
- Toggle deaktivieren → Optionen verschwinden
- „Eigener Ordner" anklicken → Pfad-Eingabe und Browse erscheinen
- Speichern → API-Aufruf mit Backup-Feldern
- [ ] **Schritt 5: Setup-Wizard starten und Seite prüfen**
```bash
dotnet run --project EngineeringSync.Setup
```
- Durch alle Schritte navigieren → Schritt 4 „Backup" erscheint
- Backup deaktivieren → Optionen ausblenden
- Aktivieren + eigenen Ordner wählen + Max auf 5 setzen
- Zusammenfassung zeigt Backup-Karte korrekt
- [ ] **Schritt 6: Final Commit**
```bash
git add .
git commit -m "feat: Backup-Einstellungen pro Projekt vollständige Implementierung"
```

View File

@@ -0,0 +1,263 @@
# 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

View File

@@ -0,0 +1,82 @@
# ======================================================================
# build-installer.ps1 - Publiziert alle Projekte und erstellt den Installer
# Aufruf: .\installer\build-installer.ps1
# ======================================================================
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot -Parent
Write-Host ""
Write-Host "+----------------------------------------------+" -ForegroundColor Cyan
Write-Host "| EngineeringSync Installer Build |" -ForegroundColor Cyan
Write-Host "+----------------------------------------------+" -ForegroundColor Cyan
Write-Host ""
# -- 1. Service publizieren (self-contained) -------------------------------------------
Write-Host "[1/4] Publiziere Windows Service (self-contained)..." -ForegroundColor Yellow
dotnet publish "$root\EngineeringSync.Service\EngineeringSync.Service.csproj" `
--configuration Release `
--runtime win-x64 `
--self-contained true `
-o "$root\EngineeringSync.Service\bin\Release\net10.0\publish"
if ($LASTEXITCODE -ne 0) { throw "Service-Publish fehlgeschlagen" }
Write-Host " [OK] Service publiziert (self-contained)" -ForegroundColor Green
# -- 2. TrayApp publizieren (self-contained) -------------------------------------------
Write-Host "[2/4] Publiziere TrayApp (self-contained)..." -ForegroundColor Yellow
dotnet publish "$root\EngineeringSync.TrayApp\EngineeringSync.TrayApp.csproj" `
--configuration Release `
--runtime win-x64 `
--self-contained true `
-o "$root\EngineeringSync.TrayApp\bin\Release\net10.0-windows\publish"
if ($LASTEXITCODE -ne 0) { throw "TrayApp-Publish fehlgeschlagen" }
Write-Host " [OK] TrayApp publiziert (self-contained)" -ForegroundColor Green
# -- 3. Setup-Wizard publizieren (self-contained) --------------------------------------
Write-Host "[3/4] Publiziere Setup-Wizard..." -ForegroundColor Yellow
dotnet publish "$root\EngineeringSync.Setup\EngineeringSync.Setup.csproj" `
--configuration Release `
--runtime win-x64 `
--self-contained true `
--output "$root\EngineeringSync.Setup\bin\Release\net10.0-windows\publish"
if ($LASTEXITCODE -ne 0) { throw "Setup-Wizard-Publish fehlgeschlagen" }
Write-Host " [OK] Setup-Wizard publiziert (self-contained)" -ForegroundColor Green
# -- 4. Inno Setup ausfuehren -----------------------------------------
Write-Host "[4/4] Erstelle Installer mit Inno Setup..." -ForegroundColor Yellow
$isccCmd = Get-Command "iscc" -ErrorAction SilentlyContinue
$isccPaths = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
$(if ($isccCmd) { $isccCmd.Source } else { $null })
) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
if (-not $isccPaths) {
Write-Host ""
Write-Host " WARNUNG: Inno Setup nicht gefunden!" -ForegroundColor Red
Write-Host " Bitte installieren: https://jrsoftware.org/isinfo.php" -ForegroundColor Red
Write-Host ""
Write-Host " Alternativ manuell kompilieren:" -ForegroundColor Yellow
Write-Host " ISCC.exe ""$PSScriptRoot\setup.iss""" -ForegroundColor Yellow
exit 1
}
New-Item -ItemType Directory -Force -Path "$root\dist" | Out-Null
& $isccPaths "$PSScriptRoot\setup.iss"
if ($LASTEXITCODE -ne 0) { throw "Inno Setup fehlgeschlagen" }
Write-Host ""
Write-Host "+----------------------------------------------+" -ForegroundColor Green
Write-Host "| [OK] Installer erfolgreich erstellt! |" -ForegroundColor Green
Write-Host "+----------------------------------------------+" -ForegroundColor Green
Write-Host ""
Write-Host " Ausgabe: $root\dist" -ForegroundColor Cyan
# Installer im Explorer oeffnen
$distPath = "$root\dist"
Start-Process "explorer.exe" $distPath

190
installer/setup.iss Normal file
View File

@@ -0,0 +1,190 @@
; ======================================================================
; EngineeringSync - Inno Setup Script
; Erstellt einen professionellen Windows-Installer (.exe)
;
; Voraussetzung: Inno Setup 6.x (https://jrsoftware.org/isinfo.php)
; Build: .\installer\build-installer.ps1
; ======================================================================
#define AppName "EngineeringSync"
#define AppVersion "1.0.0"
#define AppPublisher "EngineeringSync"
#define AppExe "EngineeringSync.TrayApp.exe"
#define ServiceExe "EngineeringSync.Service.exe"
#define SetupExe "EngineeringSync.Setup.exe"
#define DefaultDir "{autopf}\EngineeringSync"
#define TrayIcon "..\EngineeringSync.TrayApp\Assets\tray.ico"
; -- Pfade zu den publizierten Builds (relative zum .iss-Verzeichnis) --
#define ServicePublish "..\EngineeringSync.Service\bin\Release\net10.0\publish\"
#define TrayPublish "..\EngineeringSync.TrayApp\bin\Release\net10.0-windows\publish\"
#define SetupPublish "..\EngineeringSync.Setup\bin\Release\net10.0-windows\publish\"
; Self-contained: .NET Runtime wird nicht mehr separat benoetigt
[Setup]
AppId = {{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}
AppName = {#AppName}
AppVersion = {#AppVersion}
AppPublisher = {#AppPublisher}
AppPublisherURL = https://github.com/your-org/EngineeringSync
AppSupportURL = https://github.com/your-org/EngineeringSync/issues
DefaultDirName = {#DefaultDir}
DefaultGroupName = {#AppName}
DisableProgramGroupPage = yes
PrivilegesRequired = admin
OutputBaseFilename = EngineeringSync-{#AppVersion}-Setup
OutputDir = ..\dist
Compression = lzma2/ultra64
SolidCompression = yes
ArchitecturesAllowed = x64compatible
ArchitecturesInstallIn64BitMode = x64compatible
; Installer-Icon (Titelleiste + Explorer)
SetupIconFile = {#TrayIcon}
; Wizard-Design
WizardStyle = modern
WizardResizable = no
WindowVisible = no
; Versionsinformationen
VersionInfoVersion = {#AppVersion}
VersionInfoCompany = {#AppPublisher}
VersionInfoDescription = {#AppName} Setup
VersionInfoProductName = {#AppName}
; Deinstallation
UninstallDisplayName = {#AppName}
UninstallDisplayIcon = {app}\{#AppExe}
CreateUninstallRegKey = yes
[Languages]
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
[Messages]
WelcomeLabel1=Willkommen beim [name]-Setup-Assistenten
WelcomeLabel2=Dieser Assistent installiert [name/ver] auf Ihrem Computer.%n%nBitte schließen Sie alle anderen Anwendungen bevor Sie fortfahren.
FinishedLabel=Die Installation von [name] ist abgeschlossen.%n%nDer Setup-Assistent wird jetzt geöffnet, um Ihre erste Konfiguration einzurichten.
ClickFinish=Klicken Sie auf Fertigstellen um den EngineeringSync-Konfigurations-Assistenten zu starten.
[Types]
Name: "full"; Description: "Vollständige Installation"
Name: "custom"; Description: "Benutzerdefinierte Installation"; Flags: iscustom
[Components]
Name: "service"; Description: "EngineeringSync Windows-Dienst (erforderlich)"; Types: full custom; Flags: fixed
Name: "trayapp"; Description: "System Tray Benachrichtigungs-App"; Types: full custom; Flags: fixed
Name: "setup"; Description: "Konfigurations-Assistent"; Types: full custom; Flags: fixed
[Tasks]
Name: "desktopicon"; Description: "Desktop-Verknüpfung erstellen"; GroupDescription: "Zusätzliche Symbole:"
[Files]
; -- Windows Service --
Source: "{#ServicePublish}*"; DestDir: "{app}"; Components: service; Flags: ignoreversion recursesubdirs
; -- TrayApp --
Source: "{#TrayPublish}*"; DestDir: "{app}"; Components: trayapp; Flags: ignoreversion recursesubdirs
; -- Setup-Wizard --
Source: "{#SetupPublish}*"; DestDir: "{app}"; Components: setup; Flags: ignoreversion recursesubdirs
; -- Icons als eigenständige Dateien (für Verknüpfungen) --
Source: "..\EngineeringSync.TrayApp\Assets\tray.ico"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\EngineeringSync.Setup\Assets\setup-icon.ico"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
; Startmenü - TrayApp
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExe}"; IconFilename: "{app}\tray.ico"; Comment: "EngineeringSync Benachrichtigungen"
; Startmenü - Konfiguration
Name: "{group}\{#AppName} konfigurieren"; Filename: "{app}\{#SetupExe}"; IconFilename: "{app}\setup-icon.ico"; Comment: "EngineeringSync konfigurieren"
; Startmenü - Deinstallieren
Name: "{group}\{#AppName} deinstallieren"; Filename: "{uninstallexe}"; IconFilename: "{app}\tray.ico"
; Desktop-Verknüpfung (nur wenn Task gewählt)
Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExe}"; IconFilename: "{app}\tray.ico"; Comment: "EngineeringSync Benachrichtigungen"; Tasks: desktopicon
[Run]
; Windows-Dienst registrieren
Filename: "{sys}\sc.exe"; \
Parameters: "create EngineeringSync binPath= ""{app}\{#ServiceExe}"" start= auto DisplayName= ""EngineeringSync Watcher Service"""; \
StatusMsg: "Windows-Dienst wird registriert..."; \
Flags: runhidden waituntilterminated; \
Check: not ServiceExists
; Konfigurations-Wizard starten (nach der Installation)
Filename: "{app}\{#SetupExe}"; \
Description: "EngineeringSync jetzt konfigurieren"; \
StatusMsg: "Konfigurations-Assistent wird gestartet..."; \
Flags: postinstall nowait skipifsilent
[UninstallRun]
; Dienst stoppen
Filename: "{sys}\sc.exe"; Parameters: "stop EngineeringSync"; \
Flags: runhidden waituntilterminated; RunOnceId: "StopService"
; Dienst löschen
Filename: "{sys}\sc.exe"; Parameters: "delete EngineeringSync"; \
Flags: runhidden waituntilterminated; RunOnceId: "DeleteService"
; TrayApp beenden
Filename: "{sys}\taskkill.exe"; Parameters: "/F /IM ""{#AppExe}"""; \
Flags: runhidden waituntilterminated; RunOnceId: "KillTray"
[UninstallDelete]
Type: filesandordirs; Name: "{commonappdata}\EngineeringSync"
[Registry]
; TrayApp-Autostart entfernen bei Deinstallation
Root: HKCU; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; \
ValueType: none; ValueName: "EngineeringSync.TrayApp"; Flags: deletevalue uninsdeletevalue
[Code]
{ ======================================================================
Pascal-Script
====================================================================== }
function ServiceExists(): Boolean;
var
ResultCode: Integer;
begin
Exec(ExpandConstant('{sys}\sc.exe'), 'query EngineeringSync',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Result := (ResultCode = 0);
end;
{ Upgrade: Bestehende Installation erkennen und stoppen }
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ResultCode: Integer;
begin
Result := '';
if ServiceExists() then
begin
Exec(ExpandConstant('{sys}\sc.exe'), 'stop EngineeringSync',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Sleep(2000);
end;
{ TrayApp beenden falls läuft }
Exec(ExpandConstant('{sys}\taskkill.exe'),
'/F /IM "EngineeringSync.TrayApp.exe"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;

210
plan.md Normal file
View File

@@ -0,0 +1,210 @@
# Project Context: Engineering-to-Simulation Sync Tool ("EngineeringSync")
You are an expert C# / .NET 10 developer. Build a middleware tool that bridges mechanical engineering (CAD) and simulation (Process Simulate).
**Problem:** Engineers save files to a shared folder. Immediate overwrites in the simulation folder cause data corruption for the simulator. This tool watches the engineering folder, records changes, and notifies the simulation engineer via a WPF System Tray App. The simulation engineer reviews changes, selects what to sync, and triggers a controlled copy.
---
## Tech Stack
- **Framework:** .NET 10
- **Service:** Worker Service (Windows Service) + ASP.NET Core Minimal API + SignalR
- **Client:** WPF (.NET 10-Windows) with `H.NotifyIcon.Wpf`
- **Database:** SQLite via EF Core 10 (WAL mode for concurrency)
- **File Watching:** `System.IO.FileSystemWatcher` + `System.Threading.Channels` (debouncing)
- **UI Pattern:** MVVM with `CommunityToolkit.Mvvm`
---
## Phase 1: Solution Initialization
Create solution structure via .NET CLI:
```
EngineeringSync.sln
├── EngineeringSync.Domain (Class Library, net10.0) Entities, Enums, Interfaces
├── EngineeringSync.Infrastructure (Class Library, net10.0) EF Core, AppDbContext, Migrations
├── EngineeringSync.Service (Worker Service, net10.0) Kestrel :5050, SignalR, Watcher
└── EngineeringSync.TrayApp (WPF App, net10.0-windows) System tray + all UI windows
```
**Dependency direction (strict one-way):**
- `Infrastructure``Domain`
- `Service``Domain` + `Infrastructure`
- `TrayApp``Domain` (for shared DTOs/Enums only)
**NuGet Packages:**
| Project | Packages |
|---|---|
| Domain | *(none pure POCOs)* |
| Infrastructure | `Microsoft.EntityFrameworkCore.Sqlite`, `Microsoft.EntityFrameworkCore.Design` |
| Service | `Microsoft.Extensions.Hosting.WindowsServices`, `Microsoft.AspNetCore.SignalR` |
| TrayApp | `H.NotifyIcon.Wpf`, `Microsoft.AspNetCore.SignalR.Client`, `CommunityToolkit.Mvvm` |
---
## Phase 2: Domain & Database Modeling
### Domain Entities (`EngineeringSync.Domain`)
**`ProjectConfig`** A watched project (persisted in DB, managed via UI):
- `Id` (Guid, PK)
- `Name` (string) Display name, e.g. "Hauptprojekt Karosserie"
- `EngineeringPath` (string) Source folder the watcher monitors
- `SimulationPath` (string) Target folder for controlled sync
- `FileExtensions` (string) Comma-separated, e.g. ".jt,.cojt,.xml"
- `IsActive` (bool) Enable/disable watching without deleting
- `CreatedAt` (DateTime)
**`FileRevision`** Tracks known file state for change detection:
- `Id` (Guid, PK)
- `ProjectId` (Guid, FK → ProjectConfig)
- `RelativePath` (string)
- `FileHash` (string) SHA-256
- `Size` (long)
- `LastModified` (DateTime)
**`PendingChange`** A detected change awaiting user action:
- `Id` (Guid, PK)
- `ProjectId` (Guid, FK → ProjectConfig)
- `RelativePath` (string)
- `ChangeType` (Enum: Created, Modified, Renamed, Deleted)
- `OldRelativePath` (string?) Only for Renamed
- `Status` (Enum: Pending, Synced, Ignored)
- `CreatedAt` (DateTime)
- `SyncedAt` (DateTime?)
### Infrastructure (`EngineeringSync.Infrastructure`)
- `AppDbContext` with `DbSet<ProjectConfig>`, `DbSet<FileRevision>`, `DbSet<PendingChange>`
- SQLite connection string with `Mode=ReadWriteCreate` and WAL pragma
- Auto-migration at service startup via `context.Database.Migrate()`
- Unique index on `(ProjectId, RelativePath)` for `FileRevision`
---
## Phase 3: The Windows Service (Core Logic)
### 3.1 Project Management API (CRUD)
New endpoints for managing projects from the TrayApp UI:
- `GET /api/projects` List all ProjectConfig
- `POST /api/projects` Create new ProjectConfig (validates paths exist)
- `PUT /api/projects/{id}` Update ProjectConfig (restarts watcher for that project)
- `DELETE /api/projects/{id}` Delete ProjectConfig + associated changes
When a ProjectConfig is created or updated with `IsActive = true`, the WatcherService must dynamically start/stop the corresponding `FileSystemWatcher`.
### 3.2 FileSystemWatcher & Debouncing (`WatcherService`)
- `BackgroundService` that manages one `FileSystemWatcher` per active `ProjectConfig`
- Listen to: `Created`, `Changed`, `Renamed`, `Deleted`
- Filter by `ProjectConfig.FileExtensions`
- **Debouncing pipeline:**
1. Push raw `FileSystemEventArgs` into a `Channel<FileEvent>` (unbounded)
2. Consumer reads from channel, groups events by `(ProjectId, RelativePath)` within a 2000ms sliding window
3. After window closes, compute SHA-256 hash of file
4. Compare hash against latest `FileRevision` for that path
5. If hash differs (or file is new): write `FileRevision` + `PendingChange` to DB
6. Broadcast via SignalR
- **Renamed:** Record old + new path, update `FileRevision.RelativePath`
- **Deleted:** Record as `ChangeType.Deleted`, remove `FileRevision`
### 3.3 Minimal API & SignalR Hub
Host Kestrel on `http://localhost:5050`:
| Method | Endpoint | Description |
|---|---|---|
| GET | `/api/projects` | List all projects |
| POST | `/api/projects` | Create project |
| PUT | `/api/projects/{id}` | Update project |
| DELETE | `/api/projects/{id}` | Delete project |
| GET | `/api/changes/{projectId}` | Pending changes for project |
| GET | `/api/changes/{projectId}/history` | Synced/ignored changes (last 100) |
| POST | `/api/sync` | Sync selected PendingChange IDs |
| POST | `/api/ignore` | Ignore selected PendingChange IDs |
**SignalR Hub** (`NotificationHub` at `/notifications`):
- `ReceiveChangeNotification(projectId, projectName, count)` Fired when new PendingChanges are persisted
- `ProjectConfigChanged()` Fired when a project is created/updated/deleted
### 3.4 Sync Logic (`SyncManager`)
When `POST /api/sync` is called with a list of PendingChange IDs:
1. Load PendingChange + ProjectConfig from DB
2. Verify source file exists at `EngineeringPath / RelativePath`
3. If target already exists in `SimulationPath`:
- Rename to `{filename}_{timestamp:yyyyMMdd_HHmmss}.bak` (timestamped to prevent overwriting previous backups)
4. Copy source → target (create subdirectories as needed)
5. Mark PendingChange as `Status = Synced`, set `SyncedAt`
6. For `ChangeType.Deleted`: Delete target file (after backup), mark Synced
7. Return result summary (success count, errors)
---
## Phase 4: The WPF Tray App
### 4.1 System Tray Setup
- `H.NotifyIcon.Wpf`: headless startup (no main window)
- Tray icon context menu:
- **"Änderungen anzeigen"** → Opens PendingChangesWindow
- **"Projekte verwalten"** → Opens ProjectManagementWindow
- **"Beenden"** → Graceful shutdown
### 4.2 SignalR Client
- Connect to `http://localhost:5050/notifications` on startup
- Auto-reconnect with exponential backoff
- On `ReceiveChangeNotification`: Show balloon tip "Neue Engineering-Daten für Projekt [Name] verfügbar. Klicken zum Überprüfen."
- On `ProjectConfigChanged`: Refresh project list in any open window
### 4.3 Project Management Window (NEW)
A WPF Window (MVVM) for managing watched projects:
- **ListView** showing all ProjectConfig entries (Name, EngineeringPath, SimulationPath, Active-Status)
- **"Neues Projekt"** button → Opens inline form or dialog:
- Name (TextBox)
- Engineering-Pfad (TextBox + FolderBrowserDialog via Button)
- Simulations-Pfad (TextBox + FolderBrowserDialog via Button)
- Dateiendungen (TextBox, comma-separated, default: ".jt,.cojt,.xml")
- Aktiv (CheckBox)
- **"Bearbeiten"** / **"Löschen"** buttons per row
- All CRUD calls go to `POST/PUT/DELETE /api/projects`
- Path validation: Check that folders exist before saving
### 4.4 Pending Changes Window
- MVVM with `DataGrid`
- **Project selector** (ComboBox) at top → fetches from `GET /api/projects`
- Columns: Datei, Änderungstyp, Zeitstempel, Auswahl (CheckBox)
- **"Ausgewählte synchronisieren"** → `POST /api/sync`
- **"Ausgewählte ignorieren"** → `POST /api/ignore`
- **"Alle synchronisieren"** / **"Alle ignorieren"** convenience buttons
- Auto-refresh when SignalR notification arrives for the selected project
---
## Phase 5: Build Order & Execution
Build step-by-step, verify each phase compiles before proceeding:
1. **Phase 1** Solution scaffolding via `dotnet` CLI. Verify `dotnet build` succeeds.
2. **Phase 2** Domain entities + EF Core DbContext + initial migration. Verify migration applies.
3. **Phase 3.1** Project CRUD API endpoints. Test with manual HTTP requests.
4. **Phase 3.2** WatcherService with Channel debouncing. Test with file drops.
5. **Phase 3.3** SignalR hub + remaining API endpoints.
6. **Phase 3.4** SyncManager copy logic.
7. **Phase 4.1+4.2** TrayApp shell with SignalR client.
8. **Phase 4.3** Project Management Window (path configuration UI).
9. **Phase 4.4** Pending Changes Window.
Use `CommunityToolkit.Mvvm` for `[ObservableProperty]`, `[RelayCommand]` source generators in all ViewModels.
Ensure all code compiles under .NET 10. Use clean architecture and dependency injection throughout.