commit 04ae8a0aaeb657b7a7647f65c1b7ef9b68c63142 Author: EngineeringSync Date: Thu Mar 26 21:52:26 2026 +0100 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..729a6f4 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e950116 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0602ea9 --- /dev/null +++ b/AGENTS.md @@ -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="\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()` where possible +- Use `IReadOnlyList` 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` 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 dbFactory, + IHubContext hub, + ILogger 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`, 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`. 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) \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7a3958b --- /dev/null +++ b/CLAUDE.md @@ -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="\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`. 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?` | diff --git a/EngineeringSync.Domain/Constants/HubMethodNames.cs b/EngineeringSync.Domain/Constants/HubMethodNames.cs new file mode 100644 index 0000000..e70fd26 --- /dev/null +++ b/EngineeringSync.Domain/Constants/HubMethodNames.cs @@ -0,0 +1,7 @@ +namespace EngineeringSync.Domain.Constants; + +public static class HubMethodNames +{ + public const string ReceiveChangeNotification = "ReceiveChangeNotification"; + public const string ProjectConfigChanged = "ProjectConfigChanged"; +} diff --git a/EngineeringSync.Domain/EngineeringSync.Domain.csproj b/EngineeringSync.Domain/EngineeringSync.Domain.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/EngineeringSync.Domain/EngineeringSync.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/EngineeringSync.Domain/Entities/FileRevision.cs b/EngineeringSync.Domain/Entities/FileRevision.cs new file mode 100644 index 0000000..5ac8827 --- /dev/null +++ b/EngineeringSync.Domain/Entities/FileRevision.cs @@ -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; } +} diff --git a/EngineeringSync.Domain/Entities/PendingChange.cs b/EngineeringSync.Domain/Entities/PendingChange.cs new file mode 100644 index 0000000..33befd1 --- /dev/null +++ b/EngineeringSync.Domain/Entities/PendingChange.cs @@ -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; } +} diff --git a/EngineeringSync.Domain/Entities/ProjectConfig.cs b/EngineeringSync.Domain/Entities/ProjectConfig.cs new file mode 100644 index 0000000..fc36f7a --- /dev/null +++ b/EngineeringSync.Domain/Entities/ProjectConfig.cs @@ -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; + /// Komma-separiert, z.B. ".jt,.cojt,.xml" + 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 FileRevisions { get; set; } = []; + public ICollection PendingChanges { get; set; } = []; + + public IEnumerable GetExtensions() + { + // Wenn leer oder "*" → alle Dateien beobachten + if (string.IsNullOrWhiteSpace(FileExtensions) || FileExtensions.Trim() == "*") + return ["*"]; + + return FileExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } +} diff --git a/EngineeringSync.Domain/Enums/ChangeStatus.cs b/EngineeringSync.Domain/Enums/ChangeStatus.cs new file mode 100644 index 0000000..93391e2 --- /dev/null +++ b/EngineeringSync.Domain/Enums/ChangeStatus.cs @@ -0,0 +1,8 @@ +namespace EngineeringSync.Domain.Entities; + +public enum ChangeStatus +{ + Pending, + Synced, + Ignored +} diff --git a/EngineeringSync.Domain/Enums/ChangeType.cs b/EngineeringSync.Domain/Enums/ChangeType.cs new file mode 100644 index 0000000..f94c711 --- /dev/null +++ b/EngineeringSync.Domain/Enums/ChangeType.cs @@ -0,0 +1,9 @@ +namespace EngineeringSync.Domain.Entities; + +public enum ChangeType +{ + Created, + Modified, + Renamed, + Deleted +} diff --git a/EngineeringSync.Infrastructure/AppDbContext.cs b/EngineeringSync.Infrastructure/AppDbContext.cs new file mode 100644 index 0000000..b7d6500 --- /dev/null +++ b/EngineeringSync.Infrastructure/AppDbContext.cs @@ -0,0 +1,52 @@ +using EngineeringSync.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace EngineeringSync.Infrastructure; + +public class AppDbContext(DbContextOptions 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 Projects => Set(); + public DbSet FileRevisions => Set(); + public DbSet PendingChanges => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(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(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(e => + { + e.HasKey(c => c.Id); + e.HasOne(c => c.Project) + .WithMany(p => p.PendingChanges) + .HasForeignKey(c => c.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/EngineeringSync.Infrastructure/EngineeringSync.Infrastructure.csproj b/EngineeringSync.Infrastructure/EngineeringSync.Infrastructure.csproj new file mode 100644 index 0000000..4c24cfe --- /dev/null +++ b/EngineeringSync.Infrastructure/EngineeringSync.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + net10.0 + enable + enable + + + diff --git a/EngineeringSync.Infrastructure/Migrations/20260325090857_InitialCreate.Designer.cs b/EngineeringSync.Infrastructure/Migrations/20260325090857_InitialCreate.Designer.cs new file mode 100644 index 0000000..900a683 --- /dev/null +++ b/EngineeringSync.Infrastructure/Migrations/20260325090857_InitialCreate.Designer.cs @@ -0,0 +1,154 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("FileHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "RelativePath") + .IsUnique(); + + b.ToTable("FileRevisions"); + }); + + modelBuilder.Entity("EngineeringSync.Domain.Entities.PendingChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChangeType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OldRelativePath") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("PendingChanges"); + }); + + modelBuilder.Entity("EngineeringSync.Domain.Entities.ProjectConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EngineeringPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/EngineeringSync.Infrastructure/Migrations/20260325090857_InitialCreate.cs b/EngineeringSync.Infrastructure/Migrations/20260325090857_InitialCreate.cs new file mode 100644 index 0000000..b7d7b88 --- /dev/null +++ b/EngineeringSync.Infrastructure/Migrations/20260325090857_InitialCreate.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EngineeringSync.Infrastructure.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Projects", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + EngineeringPath = table.Column(type: "TEXT", nullable: false), + SimulationPath = table.Column(type: "TEXT", nullable: false), + FileExtensions = table.Column(type: "TEXT", nullable: false), + IsActive = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Projects", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FileRevisions", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + RelativePath = table.Column(type: "TEXT", nullable: false), + FileHash = table.Column(type: "TEXT", nullable: false), + Size = table.Column(type: "INTEGER", nullable: false), + LastModified = table.Column(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(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + RelativePath = table.Column(type: "TEXT", nullable: false), + ChangeType = table.Column(type: "INTEGER", nullable: false), + OldRelativePath = table.Column(type: "TEXT", nullable: true), + Status = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + SyncedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FileRevisions"); + + migrationBuilder.DropTable( + name: "PendingChanges"); + + migrationBuilder.DropTable( + name: "Projects"); + } + } +} diff --git a/EngineeringSync.Infrastructure/Migrations/20260326161840_AddBackupSettingsToProjectConfig.Designer.cs b/EngineeringSync.Infrastructure/Migrations/20260326161840_AddBackupSettingsToProjectConfig.Designer.cs new file mode 100644 index 0000000..2874bad --- /dev/null +++ b/EngineeringSync.Infrastructure/Migrations/20260326161840_AddBackupSettingsToProjectConfig.Designer.cs @@ -0,0 +1,167 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("FileHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "RelativePath") + .IsUnique(); + + b.ToTable("FileRevisions"); + }); + + modelBuilder.Entity("EngineeringSync.Domain.Entities.PendingChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChangeType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OldRelativePath") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("PendingChanges"); + }); + + modelBuilder.Entity("EngineeringSync.Domain.Entities.ProjectConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BackupPath") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EngineeringPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxBackupsPerFile") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/EngineeringSync.Infrastructure/Migrations/20260326161840_AddBackupSettingsToProjectConfig.cs b/EngineeringSync.Infrastructure/Migrations/20260326161840_AddBackupSettingsToProjectConfig.cs new file mode 100644 index 0000000..f40c4fa --- /dev/null +++ b/EngineeringSync.Infrastructure/Migrations/20260326161840_AddBackupSettingsToProjectConfig.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EngineeringSync.Infrastructure.Migrations +{ + /// + public partial class AddBackupSettingsToProjectConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BackupEnabled", + table: "Projects", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "BackupPath", + table: "Projects", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "MaxBackupsPerFile", + table: "Projects", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BackupEnabled", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "BackupPath", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "MaxBackupsPerFile", + table: "Projects"); + } + } +} diff --git a/EngineeringSync.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/EngineeringSync.Infrastructure/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..91bf35a --- /dev/null +++ b/EngineeringSync.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,164 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("FileHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "RelativePath") + .IsUnique(); + + b.ToTable("FileRevisions"); + }); + + modelBuilder.Entity("EngineeringSync.Domain.Entities.PendingChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChangeType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OldRelativePath") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("PendingChanges"); + }); + + modelBuilder.Entity("EngineeringSync.Domain.Entities.ProjectConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BackupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BackupPath") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EngineeringPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MaxBackupsPerFile") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/EngineeringSync.Infrastructure/ServiceCollectionExtensions.cs b/EngineeringSync.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..68a3f4b --- /dev/null +++ b/EngineeringSync.Infrastructure/ServiceCollectionExtensions.cs @@ -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(options => + options.UseSqlite(connectionString)); + return services; + } +} diff --git a/EngineeringSync.Service/Api/ChangesApi.cs b/EngineeringSync.Service/Api/ChangesApi.cs new file mode 100644 index 0000000..cfc6d4b --- /dev/null +++ b/EngineeringSync.Service/Api/ChangesApi.cs @@ -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 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 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; + } +} diff --git a/EngineeringSync.Service/Api/ProjectsApi.cs b/EngineeringSync.Service/Api/ProjectsApi.cs new file mode 100644 index 0000000..4d4acba --- /dev/null +++ b/EngineeringSync.Service/Api/ProjectsApi.cs @@ -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 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 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 dbFactory, + WatcherService watcher, IHubContext 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 dbFactory, WatcherService watcher, IHubContext 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 dbFactory, + WatcherService watcher, IHubContext 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() + : 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(); + 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; + } +} diff --git a/EngineeringSync.Service/EngineeringSync.Service.csproj b/EngineeringSync.Service/EngineeringSync.Service.csproj new file mode 100644 index 0000000..371cf51 --- /dev/null +++ b/EngineeringSync.Service/EngineeringSync.Service.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + dotnet-EngineeringSync.Service-6b7e2096-9433-4768-9912-b8f8fa4ad393 + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/EngineeringSync.Service/Hubs/NotificationHub.cs b/EngineeringSync.Service/Hubs/NotificationHub.cs new file mode 100644 index 0000000..2831ae7 --- /dev/null +++ b/EngineeringSync.Service/Hubs/NotificationHub.cs @@ -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. +} diff --git a/EngineeringSync.Service/Models/ApiModels.cs b/EngineeringSync.Service/Models/ApiModels.cs new file mode 100644 index 0000000..dd3b5b3 --- /dev/null +++ b/EngineeringSync.Service/Models/ApiModels.cs @@ -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 ChangeIds); +public record IgnoreRequest(List ChangeIds); + +public record ScanRequest( + string EngineeringPath, + string SimulationPath, + string FileExtensions +); + +public record ScanResultEntry( + string RelativePath, + string ChangeType, + long Size, + DateTime LastModified +); diff --git a/EngineeringSync.Service/Models/FileEvent.cs b/EngineeringSync.Service/Models/FileEvent.cs new file mode 100644 index 0000000..4e6b00d --- /dev/null +++ b/EngineeringSync.Service/Models/FileEvent.cs @@ -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 +} diff --git a/EngineeringSync.Service/Program.cs b/EngineeringSync.Service/Program.cs new file mode 100644 index 0000000..8bdbf74 --- /dev/null +++ b/EngineeringSync.Service/Program.cs @@ -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(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); + +// 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(); + 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(); + 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("/notifications"); +app.MapProjectsApi(); +app.MapChangesApi(); + +app.Run(); diff --git a/EngineeringSync.Service/Properties/launchSettings.json b/EngineeringSync.Service/Properties/launchSettings.json new file mode 100644 index 0000000..6cc2326 --- /dev/null +++ b/EngineeringSync.Service/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "EngineeringSync.Service": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/EngineeringSync.Service/Services/FileHasher.cs b/EngineeringSync.Service/Services/FileHasher.cs new file mode 100644 index 0000000..575214f --- /dev/null +++ b/EngineeringSync.Service/Services/FileHasher.cs @@ -0,0 +1,14 @@ +using System.Security.Cryptography; + +namespace EngineeringSync.Service.Services; + +public static class FileHasher +{ + public static async Task 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); + } +} diff --git a/EngineeringSync.Service/Services/SyncManager.cs b/EngineeringSync.Service/Services/SyncManager.cs new file mode 100644 index 0000000..5503540 --- /dev/null +++ b/EngineeringSync.Service/Services/SyncManager.cs @@ -0,0 +1,143 @@ +using EngineeringSync.Domain.Entities; +using EngineeringSync.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace EngineeringSync.Service.Services; + +public class SyncManager(IDbContextFactory dbFactory, ILogger logger) +{ + public async Task SyncAsync(IEnumerable 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 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); diff --git a/EngineeringSync.Service/Services/WatcherService.cs b/EngineeringSync.Service/Services/WatcherService.cs new file mode 100644 index 0000000..a95f251 --- /dev/null +++ b/EngineeringSync.Service/Services/WatcherService.cs @@ -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; + +/// +/// 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). +/// +public sealed class WatcherService( + IDbContextFactory dbFactory, + IHubContext hub, + ILogger logger) : BackgroundService +{ + // Unbounded Channel: FSW-Events kommen schnell, Verarbeitung ist langsam + private readonly Channel _channel = + Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + // Watcher pro Projekt-ID – wird dynamisch verwaltet + private readonly Dictionary _watchers = []; + private readonly SemaphoreSlim _watcherLock = new(1, 1); + private readonly ILogger _logger = logger; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Alle aktiven Projekte beim Start laden + List 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); + } + + /// Startet Watcher für ein Projekt. Idempotent – stoppt alten Watcher zuerst. + 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(); + } + } + + /// + /// Initialer Scan: Vergleicht Engineering- und Simulations-Ordner. + /// Erkennt Dateien, die im Engineering-Ordner vorhanden sind, aber im + /// Simulations-Ordner fehlen oder sich unterscheiden. + /// + 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); + } + } + + /// Stoppt und entfernt Watcher für ein Projekt. + 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)); + } + + /// + /// Debouncing-Consumer: Gruppiert Events nach (ProjectId, RelativePath) innerhalb + /// eines 2000ms-Fensters. Verhindert, dass eine Datei 10× verarbeitet wird. + /// + 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); + } + + /// + /// Berechnet den Hash einer Datei mit Retry-Logik. + /// Versucht bis zu 3 Mal, mit exponentieller Backoff-Wartezeit. + /// Wirft IOException, wenn alle Versuche scheitern. + /// + private async Task 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(); + } +} diff --git a/EngineeringSync.Service/Worker.cs b/EngineeringSync.Service/Worker.cs new file mode 100644 index 0000000..1c30f9d --- /dev/null +++ b/EngineeringSync.Service/Worker.cs @@ -0,0 +1,16 @@ +namespace EngineeringSync.Service; + +public class Worker(ILogger 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); + } + } +} diff --git a/EngineeringSync.Service/appsettings.Development.json b/EngineeringSync.Service/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/EngineeringSync.Service/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/EngineeringSync.Service/appsettings.json b/EngineeringSync.Service/appsettings.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/EngineeringSync.Service/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/EngineeringSync.Setup/App.xaml b/EngineeringSync.Setup/App.xaml new file mode 100644 index 0000000..5492bb4 --- /dev/null +++ b/EngineeringSync.Setup/App.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/App.xaml.cs b/EngineeringSync.Setup/App.xaml.cs new file mode 100644 index 0000000..45ed2d7 --- /dev/null +++ b/EngineeringSync.Setup/App.xaml.cs @@ -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); + } +} + diff --git a/EngineeringSync.Setup/AssemblyInfo.cs b/EngineeringSync.Setup/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/EngineeringSync.Setup/AssemblyInfo.cs @@ -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) +)] diff --git a/EngineeringSync.Setup/Assets/setup-icon.ico b/EngineeringSync.Setup/Assets/setup-icon.ico new file mode 100644 index 0000000..9cfa284 Binary files /dev/null and b/EngineeringSync.Setup/Assets/setup-icon.ico differ diff --git a/EngineeringSync.Setup/Converters/WizardConverters.cs b/EngineeringSync.Setup/Converters/WizardConverters.cs new file mode 100644 index 0000000..b40a907 --- /dev/null +++ b/EngineeringSync.Setup/Converters/WizardConverters.cs @@ -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(); +} + +/// +/// Gibt "Jetzt installieren" wenn true (letzter normaler Schritt), sonst "Weiter". +/// +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(); +} diff --git a/EngineeringSync.Setup/EngineeringSync.Setup.csproj b/EngineeringSync.Setup/EngineeringSync.Setup.csproj new file mode 100644 index 0000000..8023fa6 --- /dev/null +++ b/EngineeringSync.Setup/EngineeringSync.Setup.csproj @@ -0,0 +1,23 @@ + + + + WinExe + net10.0-windows + enable + enable + true + EngineeringSync.Setup + app.manifest + Assets\setup-icon.ico + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/MainWindow.xaml b/EngineeringSync.Setup/MainWindow.xaml new file mode 100644 index 0000000..cb1335b --- /dev/null +++ b/EngineeringSync.Setup/MainWindow.xaml @@ -0,0 +1,12 @@ + + + + + diff --git a/EngineeringSync.Setup/MainWindow.xaml.cs b/EngineeringSync.Setup/MainWindow.xaml.cs new file mode 100644 index 0000000..7545e13 --- /dev/null +++ b/EngineeringSync.Setup/MainWindow.xaml.cs @@ -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; + +/// +/// Interaction logic for MainWindow.xaml +/// +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/EngineeringSync.Setup/Services/InstallerService.cs b/EngineeringSync.Setup/Services/InstallerService.cs new file mode 100644 index 0000000..d468d1c --- /dev/null +++ b/EngineeringSync.Setup/Services/InstallerService.cs @@ -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; + +/// +/// Führt alle Installationsschritte aus. Gibt Fortschritt und Log-Meldungen +/// über Events zurück, damit die UI in Echtzeit aktualisiert werden kann. +/// +public class InstallerService(WizardState state) +{ + public event Action? Progress; + public event Action? LogMessage; + + private void Report(int percent, string step, string? log = null) + { + Progress?.Invoke(percent, step); + LogMessage?.Invoke(log ?? step); + } + + /// Hauptinstallationsablauf – läuft auf einem Background-Thread. + 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); +} diff --git a/EngineeringSync.Setup/Themes/WizardStyles.xaml b/EngineeringSync.Setup/Themes/WizardStyles.xaml new file mode 100644 index 0000000..5680aff --- /dev/null +++ b/EngineeringSync.Setup/Themes/WizardStyles.xaml @@ -0,0 +1,361 @@ + + + + + #0078D4 + #106EBE + #005A9E + #1B1B2F + #16213E + #107C10 + #C42B1C + #CA5010 + #FAFAFA + #E0E0E0 + #1A1A1A + #5F5F5F + + + + + + + + + + + + + + + + + + + Segoe UI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/ViewModels/WizardState.cs b/EngineeringSync.Setup/ViewModels/WizardState.cs new file mode 100644 index 0000000..0ddbb61 --- /dev/null +++ b/EngineeringSync.Setup/ViewModels/WizardState.cs @@ -0,0 +1,36 @@ +using System.IO; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace EngineeringSync.Setup.ViewModels; + +/// +/// Zentrales Datenmodell das durch alle Wizard-Schritte weitergereicht wird. +/// +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; +} diff --git a/EngineeringSync.Setup/ViewModels/WizardViewModel.cs b/EngineeringSync.Setup/ViewModels/WizardViewModel.cs new file mode 100644 index 0000000..608b3d9 --- /dev/null +++ b/EngineeringSync.Setup/ViewModels/WizardViewModel.cs @@ -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 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> _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; +} diff --git a/EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml b/EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml new file mode 100644 index 0000000..4d13536 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml.cs b/EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml.cs new file mode 100644 index 0000000..a05d34c --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/BackupOptionsPage.xaml.cs @@ -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; + } +} \ No newline at end of file diff --git a/EngineeringSync.Setup/Views/Pages/CompletionPage.xaml b/EngineeringSync.Setup/Views/Pages/CompletionPage.xaml new file mode 100644 index 0000000..e09274e --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/CompletionPage.xaml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/CompletionPage.xaml.cs b/EngineeringSync.Setup/Views/Pages/CompletionPage.xaml.cs new file mode 100644 index 0000000..ab51a15 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/CompletionPage.xaml.cs @@ -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(); + } +} diff --git a/EngineeringSync.Setup/Views/Pages/FeatureRow.xaml b/EngineeringSync.Setup/Views/Pages/FeatureRow.xaml new file mode 100644 index 0000000..a50dc36 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/FeatureRow.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/FeatureRow.xaml.cs b/EngineeringSync.Setup/Views/Pages/FeatureRow.xaml.cs new file mode 100644 index 0000000..218ea0a --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/FeatureRow.xaml.cs @@ -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(); +} diff --git a/EngineeringSync.Setup/Views/Pages/FirstProjectPage.xaml b/EngineeringSync.Setup/Views/Pages/FirstProjectPage.xaml new file mode 100644 index 0000000..ea4a85e --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/FirstProjectPage.xaml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/InstallPathPage.xaml.cs b/EngineeringSync.Setup/Views/Pages/InstallPathPage.xaml.cs new file mode 100644 index 0000000..5a3fdb9 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/InstallPathPage.xaml.cs @@ -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; + } +} diff --git a/EngineeringSync.Setup/Views/Pages/InstallingPage.xaml b/EngineeringSync.Setup/Views/Pages/InstallingPage.xaml new file mode 100644 index 0000000..9e8a7e1 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/InstallingPage.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/InstallingPage.xaml.cs b/EngineeringSync.Setup/Views/Pages/InstallingPage.xaml.cs new file mode 100644 index 0000000..7852634 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/InstallingPage.xaml.cs @@ -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."; + }); + } +} diff --git a/EngineeringSync.Setup/Views/Pages/OptionCard.xaml b/EngineeringSync.Setup/Views/Pages/OptionCard.xaml new file mode 100644 index 0000000..79d7b0f --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/OptionCard.xaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/OptionCard.xaml.cs b/EngineeringSync.Setup/Views/Pages/OptionCard.xaml.cs new file mode 100644 index 0000000..39c4d95 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/OptionCard.xaml.cs @@ -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); + } +} diff --git a/EngineeringSync.Setup/Views/Pages/ServiceOptionsPage.xaml b/EngineeringSync.Setup/Views/Pages/ServiceOptionsPage.xaml new file mode 100644 index 0000000..9c43d3f --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/ServiceOptionsPage.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/ServiceOptionsPage.xaml.cs b/EngineeringSync.Setup/Views/Pages/ServiceOptionsPage.xaml.cs new file mode 100644 index 0000000..0e08e9c --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/ServiceOptionsPage.xaml.cs @@ -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(); + } +} diff --git a/EngineeringSync.Setup/Views/Pages/SummaryBoolRow.xaml b/EngineeringSync.Setup/Views/Pages/SummaryBoolRow.xaml new file mode 100644 index 0000000..a57dcd3 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/SummaryBoolRow.xaml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/SummaryBoolRow.xaml.cs b/EngineeringSync.Setup/Views/Pages/SummaryBoolRow.xaml.cs new file mode 100644 index 0000000..296f0c7 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/SummaryBoolRow.xaml.cs @@ -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)); + } + } +} diff --git a/EngineeringSync.Setup/Views/Pages/SummaryPage.xaml b/EngineeringSync.Setup/Views/Pages/SummaryPage.xaml new file mode 100644 index 0000000..f00c857 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/SummaryPage.xaml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/SummaryPage.xaml.cs b/EngineeringSync.Setup/Views/Pages/SummaryPage.xaml.cs new file mode 100644 index 0000000..1d8cc12 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/SummaryPage.xaml.cs @@ -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(); + } +} diff --git a/EngineeringSync.Setup/Views/Pages/SummaryRow.xaml b/EngineeringSync.Setup/Views/Pages/SummaryRow.xaml new file mode 100644 index 0000000..7707ff6 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/SummaryRow.xaml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/SummaryRow.xaml.cs b/EngineeringSync.Setup/Views/Pages/SummaryRow.xaml.cs new file mode 100644 index 0000000..1fc318f --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/SummaryRow.xaml.cs @@ -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(); +} diff --git a/EngineeringSync.Setup/Views/Pages/WelcomePage.xaml b/EngineeringSync.Setup/Views/Pages/WelcomePage.xaml new file mode 100644 index 0000000..ac69069 --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/WelcomePage.xaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/Pages/WelcomePage.xaml.cs b/EngineeringSync.Setup/Views/Pages/WelcomePage.xaml.cs new file mode 100644 index 0000000..48134ee --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/WelcomePage.xaml.cs @@ -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(); + } +} diff --git a/EngineeringSync.Setup/Views/Pages/WizardPageBase.cs b/EngineeringSync.Setup/Views/Pages/WizardPageBase.cs new file mode 100644 index 0000000..38d614d --- /dev/null +++ b/EngineeringSync.Setup/Views/Pages/WizardPageBase.cs @@ -0,0 +1,25 @@ +using System.Windows.Controls; +using EngineeringSync.Setup.ViewModels; + +namespace EngineeringSync.Setup.Views.Pages; + +/// +/// Basisklasse für alle Wizard-Seiten. +/// Stellt gemeinsame WizardViewModel-Referenz und Validierungsschnittstelle bereit. +/// +public abstract class WizardPageBase : UserControl +{ + protected WizardViewModel Wizard { get; } + + protected WizardPageBase(WizardViewModel wizard) + { + Wizard = wizard; + DataContext = wizard.State; + } + + /// + /// Wird vor dem Vorwärtsnavigieren aufgerufen. + /// Gibt false zurück um die Navigation zu blockieren (z.B. Validierungsfehler). + /// + public virtual bool Validate() => true; +} diff --git a/EngineeringSync.Setup/Views/WizardWindow.xaml b/EngineeringSync.Setup/Views/WizardWindow.xaml new file mode 100644 index 0000000..8745c4e --- /dev/null +++ b/EngineeringSync.Setup/Views/WizardWindow.xaml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EngineeringSync.Setup/Views/WizardWindow.xaml.cs b/EngineeringSync.Setup/Views/WizardWindow.xaml.cs new file mode 100644 index 0000000..5bd9029 --- /dev/null +++ b/EngineeringSync.Setup/Views/WizardWindow.xaml.cs @@ -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); + } +} diff --git a/EngineeringSync.Setup/app.manifest b/EngineeringSync.Setup/app.manifest new file mode 100644 index 0000000..fe643ff --- /dev/null +++ b/EngineeringSync.Setup/app.manifest @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + PerMonitorV2 + + + diff --git a/EngineeringSync.TrayApp/App.xaml b/EngineeringSync.TrayApp/App.xaml new file mode 100644 index 0000000..ac45383 --- /dev/null +++ b/EngineeringSync.TrayApp/App.xaml @@ -0,0 +1,11 @@ + + + + + diff --git a/EngineeringSync.TrayApp/App.xaml.cs b/EngineeringSync.TrayApp/App.xaml.cs new file mode 100644 index 0000000..2609ce6 --- /dev/null +++ b/EngineeringSync.TrayApp/App.xaml.cs @@ -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().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); + } +} diff --git a/EngineeringSync.TrayApp/AssemblyInfo.cs b/EngineeringSync.TrayApp/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/EngineeringSync.TrayApp/AssemblyInfo.cs @@ -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) +)] diff --git a/EngineeringSync.TrayApp/Assets/tray.ico b/EngineeringSync.TrayApp/Assets/tray.ico new file mode 100644 index 0000000..9cfa284 Binary files /dev/null and b/EngineeringSync.TrayApp/Assets/tray.ico differ diff --git a/EngineeringSync.TrayApp/Converters/Converters.cs b/EngineeringSync.TrayApp/Converters/Converters.cs new file mode 100644 index 0000000..b0f83d3 --- /dev/null +++ b/EngineeringSync.TrayApp/Converters/Converters.cs @@ -0,0 +1,32 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace EngineeringSync.TrayApp.Converters; + +/// Gibt "Neues Projekt" oder "Projekt bearbeiten" zurück. +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(); +} + +/// Gibt Visibility.Visible zurück wenn der String nicht leer ist. +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(); +} + +/// Invertiert Boolean für Visibility (true = Collapsed, false = Visible). +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(); +} diff --git a/EngineeringSync.TrayApp/EngineeringSync.TrayApp.csproj b/EngineeringSync.TrayApp/EngineeringSync.TrayApp.csproj new file mode 100644 index 0000000..0ac5f6c --- /dev/null +++ b/EngineeringSync.TrayApp/EngineeringSync.TrayApp.csproj @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + WinExe + net10.0-windows + enable + enable + true + + Assets\tray.ico + + + + + + + diff --git a/EngineeringSync.TrayApp/MainWindow.xaml b/EngineeringSync.TrayApp/MainWindow.xaml new file mode 100644 index 0000000..352e36a --- /dev/null +++ b/EngineeringSync.TrayApp/MainWindow.xaml @@ -0,0 +1,12 @@ + + + + + diff --git a/EngineeringSync.TrayApp/MainWindow.xaml.cs b/EngineeringSync.TrayApp/MainWindow.xaml.cs new file mode 100644 index 0000000..d5ab279 --- /dev/null +++ b/EngineeringSync.TrayApp/MainWindow.xaml.cs @@ -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; + +/// +/// Interaction logic for MainWindow.xaml +/// +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/EngineeringSync.TrayApp/Services/ApiClient.cs b/EngineeringSync.TrayApp/Services/ApiClient.cs new file mode 100644 index 0000000..59acfcd --- /dev/null +++ b/EngineeringSync.TrayApp/Services/ApiClient.cs @@ -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?> GetProjectsAsync() => + http.GetFromJsonAsync>($"{Base}/projects"); + + public Task?> GetChangesAsync(Guid projectId) => + http.GetFromJsonAsync>($"{Base}/changes/{projectId}"); + + public Task?> ScanFoldersAsync(string engineeringPath, string simulationPath, string fileExtensions) => + http.GetFromJsonAsync>($"{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 ids) + { + var resp = await http.PostAsJsonAsync($"{Base}/sync", new { ChangeIds = ids }); + resp.EnsureSuccessStatusCode(); + } + + public async Task IgnoreChangesAsync(List 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); diff --git a/EngineeringSync.TrayApp/Services/SignalRService.cs b/EngineeringSync.TrayApp/Services/SignalRService.cs new file mode 100644 index 0000000..26f23a1 --- /dev/null +++ b/EngineeringSync.TrayApp/Services/SignalRService.cs @@ -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? 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(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(); + } +} diff --git a/EngineeringSync.TrayApp/ViewModels/PendingChangesViewModel.cs b/EngineeringSync.TrayApp/ViewModels/PendingChangesViewModel.cs new file mode 100644 index 0000000..6b40c5f --- /dev/null +++ b/EngineeringSync.TrayApp/ViewModels/PendingChangesViewModel.cs @@ -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 _projects = []; + [ObservableProperty] private ProjectConfig? _selectedProject; + [ObservableProperty] private ObservableCollection _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(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( + 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"); +} diff --git a/EngineeringSync.TrayApp/ViewModels/ProjectManagementViewModel.cs b/EngineeringSync.TrayApp/ViewModels/ProjectManagementViewModel.cs new file mode 100644 index 0000000..8357b23 --- /dev/null +++ b/EngineeringSync.TrayApp/ViewModels/ProjectManagementViewModel.cs @@ -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 _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 _scanResults = []; + public bool HasScanResults => ScanResults.Count > 0; + + [RelayCommand] + public async Task LoadAsync() + { + var projects = await api.GetProjectsAsync() ?? []; + Projects = new ObservableCollection(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}"; + } + } +} diff --git a/EngineeringSync.TrayApp/Views/PendingChangesWindow.xaml b/EngineeringSync.TrayApp/Views/PendingChangesWindow.xaml new file mode 100644 index 0000000..28563ce --- /dev/null +++ b/EngineeringSync.TrayApp/Views/PendingChangesWindow.xaml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **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 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 `` des `ScrollViewer`, folgende Karte einfügen: + +```xml + + + + + + + + + + + + + +``` + +- [ ] **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 + + + + + + + + + + + + + + +