Phase 3: Avalonia-Bedienhuelle fuer Windows und Linux

Neues Projekt Predictalytics.Shell (Avalonia 12.1.1, net10.0) als
plattformuebergreifender Ersatz fuer den WinForms-Host. Setzt auf dem in
Phase 2 extrahierten Predictalytics.Hosting auf.

Oberflaeche:
- MainWindow mit Menue, Werkzeugleiste, Terminal-Tab, Einstellungen-Tab und
  Statusleiste. Die feste Fenstergroesse von 1886x1088 ist aufgeloest.
- Terminal als ListBox ueber ObservableCollection mit Einfaerbung nach
  Loglevel. Ringpuffer statt des bisherigen kompletten Leerens bei 500 Zeilen.
- Einstellungsansicht handgeschrieben als Ersatz fuer den PropertyGrid, den
  es in Avalonia nicht gibt: vier Gruppen, Passwortfelder, mehrzeiliges
  Textfeld fuer die Egress-Kanaele, ComboBox fuer den SSL-Modus. Gespeichert
  wird explizit statt bei jeder Einzeloperation.
- LicenseWindow ersetzt den WinForms-LicenseDialog.
- Dialogs.cs als schlanker Ersatz fuer MessageBox.Show, ohne Drittanbieter.

Headless-Modus (--headless) im selben Binary: fuer Linux-Server ohne
Desktop-Session. Die Argumentauswertung steht vor jeder Avalonia-
Initialisierung, sonst stirbt der Prozess ohne X11 bevor der Schalter greift.
Lizenz ueber PREDICTALYTICS_LICENSE_KEY statt Dialog, SIGTERM und SIGINT
ueber PosixSignalRegistration fuer sauberes systemd-Stop.
Exit-Codes: 0 planmaessig, 2 keine nutzbare Lizenz, 3 Fehler.

Plattformdetails: Schrift-Fallbackkette fuers Terminal, Avalonia.Fonts.Inter
als mitgelieferte UI-Schrift, UTF-8 fuer die Windows-Konsole im
Headless-Modus.

Die Avalonia-Basisklasse wird als global::Avalonia.Application angegeben:
innerhalb von Predictalytics.* loest der kurze Name auf den eigenen
Namespace Predictalytics.Application auf.

Build: 0 Fehler, 0 Warnungen. Tests: 100 bestanden.
Verifiziert: --headless laeuft bis zur Lizenzschranke und beendet sich mit
Exit-Code 2; GUI startet und zeigt das Lizenzfenster. Das Hauptfenster
konnte nicht geprueft werden, weil die hinterlegte Lizenz abgelaufen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-07 10:15:56 +02:00
co-authored by Claude Opus 5
parent 260dff1700
commit 1efd23abe4
18 changed files with 1305 additions and 1 deletions
+92
View File
@@ -0,0 +1,92 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Predictalytics.Hosting;
using Predictalytics.Shell.ViewModels;
using Predictalytics.Shell.Views;
using Serilog;
namespace Predictalytics.Shell;
/// <summary>
/// Avalonia-Anwendungsobjekt.
/// <para>
/// Die Basisklasse wird voll qualifiziert angegeben: innerhalb von
/// <c>Predictalytics.*</c> loest der kurze Name <c>Application</c> auf den
/// Namespace <c>Predictalytics.Application</c> auf, nicht auf den Avalonia-Typ.
/// </para>
/// </summary>
public partial class App : global::Avalonia.Application
{
private IDisposable? _licenseWatch;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Das Lizenzfenster darf beim Schliessen nicht die Anwendung beenden —
// danach soll ja erst das Hauptfenster kommen.
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
desktop.Exit += (_, _) => _licenseWatch?.Dispose();
// Nicht awaiten: OnFrameworkInitializationCompleted muss zurueckkehren,
// damit die Nachrichtenschleife anlaufen kann.
Dispatcher.UIThread.Post(async () => await StartupAsync(desktop));
}
base.OnFrameworkInitializationCompleted();
}
private async Task StartupAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
// ─── Lizenzschranke: ohne nutzbare Lizenz keine Anwendung ───
var client = LicenseGuard.CreateClient();
var result = await LicenseGuard.RevalidateAsync(client);
if (!LicenseGuard.IsUsable(client, result))
{
var licenseWindow = new LicenseWindow(client, result);
desktop.MainWindow = licenseWindow;
licenseWindow.Show();
if (!await licenseWindow.Completion)
{
desktop.Shutdown();
return;
}
}
// ─── Hauptfenster ───
var viewModel = new MainWindowViewModel();
var window = new MainWindow(viewModel);
// Serilog-Aufbau liegt in Predictalytics.Hosting; hier kommt nur der
// Terminal-Sink dazu, der selbst auf den UI-Thread marshallt.
LoggingSetup.Configure(viewModel.AppendLog);
LoggingSetup.LogStartupBanner();
desktop.MainWindow = window;
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
window.Show();
viewModel.Start();
// Waehrend des Betriebs alle 12 h nachpruefen (Widerruf, Ablauf, Offline-Frist).
_licenseWatch = LicenseGuard.StartPeriodicRevalidation(client, unusable =>
Dispatcher.UIThread.Post(async () =>
{
await Dialogs.ShowErrorAsync(window, "Lizenzfehler",
$"Die Lizenz ist nicht mehr gültig ({unusable.State}):\n{unusable.Message}\n\nPredictalytics wird beendet.");
desktop.Shutdown();
}));
desktop.Exit += (_, _) =>
{
Log.Information("Application shutting down.");
Log.CloseAndFlush();
};
}
}