Avalonia-Grundgeruest: plattformneutrale App laeuft (Shell + erstes Fenster)
Neues Projekt src/PolyTrader.App.Avalonia (net10.0, Avalonia 12.1.1, LiveCharts2 2.0.5) - laeuft unter Windows und Linux aus derselben Quelle. Enthalten: - Program.cs mit bewusst getrenntem Aufbau: BuildHost() stellt Persistenz, Dienste und Module ohne jeden UI-Bezug zusammen, erst Main haengt Avalonia daran. Damit ist der kopflose Linux-Betrieb (--headless, Stufe L2) ohne Umbau erreichbar - der Schalter ist bereits drin. - AvaloniaUiHost als IModuleUiHost: gleiche Semantik wie die WinForms-Shell (ein Fenster je View, offene nach vorn holen, alles maximiert). - Fenster-Menueleiste vollstaendig DEKLARATIV (Controls/WindowMenuBar.axaml + ItemsSource auf WindowMenuModel.Entries). Loest die alte Fassung ab, die menu.Items zur Laufzeit leerte und neu befuellte - genau der Punkt, den die neue Layout-Regel verbietet. - ViewIcons fuer Avalonia: dieselben Schluessel und dieselben PNGs wie zuvor, Core und Module bleiben unveraendert. - LauncherWindow, JobsWindow, ShutdownConfirmWindow (inkl. der 10-Sekunden-Sperre). - --smoke-ui als Nachfolger der WinForms-Konstruktionspruefung; startet den Host bewusst NICHT, damit ein reiner UI-Test nicht die Trading-Engine gegen echte Endpunkte anwirft. Dabei aufgeraeumt: - JobManager.Jobs: BindingList -> ObservableCollection. BindingList implementiert kein INotifyCollectionChanged; neu registrierte Jobs waeren in Avalonia unsichtbar geblieben. - StartupHydrationService aufgeteilt in CoreStateHydrationService (Core: Accounts + Demo-Positionen) und CopyTradingHydrationService (Modul: Settings + Trader). Behebt einen latenten Fehler: bei deaktiviertem Copytrading-Modul waeren die Accounts gar nicht mehr hydriert worden, obwohl sie zum Core gehoeren. Verifiziert: Solution baut, 442 Tests gruen, --smoke-ui gruen, die App laeuft real mit Fenster und allen Trading-Diensten (Market-Sync, Master-Trader-Analyse, RF-Scanner), und publisht fuer linux-x64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTrader.App.Avalonia.Shell;
|
||||
using PolyTrader.Core.Configuration;
|
||||
using PolyTrader.Core.DependencyInjection;
|
||||
using PolyTrader.Core.Modularity;
|
||||
using PolyTrader.Core.Notifications;
|
||||
using PolyTrader.Core.Security;
|
||||
using PolyTrader.Modules.Accounting;
|
||||
using PolyTrader.Modules.CopyTrading;
|
||||
using PolyTrader.Modules.ResolutionFarming;
|
||||
using PolyTrader.Modules.Supervisor;
|
||||
using PolyTraderSharp;
|
||||
using PolyTraderSharp.Models;
|
||||
using PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTrader.App.Avalonia
|
||||
{
|
||||
/// <summary>
|
||||
/// Einstiegspunkt der plattformneutralen PolyTrader-Anwendung.
|
||||
///
|
||||
/// <para>Der Aufbau ist bewusst zweigeteilt: <see cref="BuildHost"/> stellt den Generic Host mit
|
||||
/// Persistenz, Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst
|
||||
/// <see cref="Main"/> hängt Avalonia daran. Damit lässt sich derselbe Host später ohne Umbau
|
||||
/// für den kopflosen Linux-Betrieb starten (Schalter <c>--headless</c>, Stufe L2).</para>
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private const string ServerSettingsPath = "server_settings.xml";
|
||||
|
||||
[STAThread]
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
bool headless = args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase));
|
||||
bool smoke = args.Any(a => string.Equals(a, "--smoke-ui", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var host = BuildHost(out var bootLog);
|
||||
|
||||
// Konstruktionspruefung der Fenster: Host NICHT starten. Sonst laufen Trading-Engine,
|
||||
// WSS-Clients und Jobs gegen die echten Boersen-Endpunkte an - fuer einen reinen
|
||||
// UI-Test unerwuenscht (und auf einem Build-Server schlicht falsch).
|
||||
if (smoke)
|
||||
{
|
||||
try { return RunSmokeUi(host, bootLog); }
|
||||
finally { host.Dispose(); }
|
||||
}
|
||||
|
||||
host.Start();
|
||||
|
||||
try
|
||||
{
|
||||
if (headless)
|
||||
{
|
||||
bootLog.Info("Kopfloser Betrieb: keine Oberfläche, Trading-Dienste laufen. Beenden mit Strg+C.");
|
||||
host.WaitForShutdown();
|
||||
return 0;
|
||||
}
|
||||
|
||||
App.Services = host.Services;
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Geordnetes Herunterfahren der Module – erst nach dem Ende der Oberfläche.
|
||||
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
|
||||
host.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Von Avalonia (auch vom XAML-Previewer) erwartete Fabrikmethode.</summary>
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
|
||||
/// <summary>
|
||||
/// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die
|
||||
/// aktiven Module. Kennt die Oberfläche nicht.
|
||||
/// </summary>
|
||||
private static IHost BuildHost(out TerminalLogger bootLog)
|
||||
{
|
||||
var serverSettings = ServerSettings.Load(ServerSettingsPath);
|
||||
var logger = new TerminalLogger();
|
||||
bootLog = logger;
|
||||
|
||||
ConfigureSecretProtection(logger);
|
||||
|
||||
var allModules = new List<IPolyTraderModule>
|
||||
{
|
||||
new CopyTradingModule(),
|
||||
new ResolutionFarmingModule(),
|
||||
new SupervisorModule(),
|
||||
new AccountingModule()
|
||||
};
|
||||
var disabled = new HashSet<string>(serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase);
|
||||
var modules = allModules.Where(m => !disabled.Contains(m.Name)).ToList();
|
||||
|
||||
return Host.CreateDefaultBuilder()
|
||||
.UseContentRoot(AppContext.BaseDirectory)
|
||||
.ConfigureAppConfiguration((_, config) =>
|
||||
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
var databaseOptions = new DatabaseOptions
|
||||
{
|
||||
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
|
||||
};
|
||||
services.Configure<DatabaseOptions>(context.Configuration.GetSection(DatabaseOptions.SectionName));
|
||||
services.AddCorePersistence(databaseOptions);
|
||||
|
||||
services.AddSingleton(serverSettings);
|
||||
services.AddSingleton(logger);
|
||||
services.AddSingleton<TradingState>();
|
||||
services.AddSingleton<JobManager>();
|
||||
services.AddSingleton<MullvadVpnService>();
|
||||
services.AddHttpClient();
|
||||
|
||||
// Polymarket-Zugriffe teilen sich einen langlebigen Verbindungspool.
|
||||
services.AddSingleton(sp => new PolymarketApiService(
|
||||
sp.GetRequiredService<TerminalLogger>(), CreatePolymarketHttpClient()));
|
||||
services.AddSingleton(sp => new PolymarketClobClient(
|
||||
sp.GetRequiredService<TerminalLogger>(), CreatePolymarketHttpClient()));
|
||||
|
||||
// Benachrichtigungen: neutrale Senke, bis RocketChat/Telegram angebunden sind.
|
||||
services.AddSingleton<INotificationSink>(
|
||||
sp => new LogNotificationSink(sp.GetRequiredService<TerminalLogger>()));
|
||||
|
||||
services.AddSingleton(sp => new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>()));
|
||||
|
||||
// MUSS vor den Modulen registriert werden: hydriert die Accounts, bevor die
|
||||
// Trading-Dienste anlaufen (Reihenfolge = Registrierungsreihenfolge).
|
||||
services.AddHostedService<CoreStateHydrationService>();
|
||||
|
||||
foreach (var module in modules)
|
||||
{
|
||||
services.AddSingleton(module);
|
||||
module.RegisterServices(services, context.Configuration);
|
||||
}
|
||||
|
||||
services.AddHostedService<MarketSyncService>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<MullvadVpnService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WatchdogHeartbeatService>());
|
||||
|
||||
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
|
||||
services.AddSingleton<AvaloniaUiHost>();
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kopflose Konstruktionsprüfung aller registrierten Fenster – Nachfolger des
|
||||
/// <c>--smoke-ui</c>-Laufs der WinForms-App, der Konstruktionsfehler zuverlässig gefangen hat.
|
||||
/// Läuft ohne Fenster und ohne Message-Loop.
|
||||
/// </summary>
|
||||
private static int RunSmokeUi(IHost host, TerminalLogger logger)
|
||||
{
|
||||
// Avalonia muss initialisiert sein, damit Fenster konstruiert werden koennen.
|
||||
BuildAvaloniaApp().SetupWithoutStarting();
|
||||
App.Services = host.Services;
|
||||
|
||||
var uiHost = host.Services.GetRequiredService<AvaloniaUiHost>();
|
||||
CoreViews.Register(uiHost, host.Services);
|
||||
foreach (var module in host.Services.GetServices<IPolyTraderModule>())
|
||||
module.RegisterUi(uiHost, host.Services);
|
||||
ViewIcons.AssignDefaults(uiHost);
|
||||
|
||||
int failures = 0;
|
||||
Console.WriteLine("=== Smoke-UI: Fenster-Konstruktion (Avalonia) ===");
|
||||
foreach (var view in uiHost.Views)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = view.CreateView();
|
||||
Console.WriteLine($"[OK] {view.Id} ({view.Title})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures++;
|
||||
Console.WriteLine($"[FEHLER] {view.Id}: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = new Views.LauncherWindow(uiHost, host.Services);
|
||||
Console.WriteLine("[OK] LauncherWindow konstruiert");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures++;
|
||||
Console.WriteLine($"[FEHLER] LauncherWindow: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = new Views.ShutdownConfirmWindow();
|
||||
Console.WriteLine("[OK] ShutdownConfirmWindow konstruiert");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures++;
|
||||
Console.WriteLine($"[FEHLER] ShutdownConfirmWindow: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HttpClient für die Polymarket-APIs: langlebiger Verbindungspool und die Header, die die
|
||||
/// Gegenstelle erwartet (identisch zur bisherigen WinForms-Zusammenstellung).
|
||||
/// </summary>
|
||||
private static System.Net.Http.HttpClient CreatePolymarketHttpClient() =>
|
||||
new(new System.Net.Http.SocketsHttpHandler
|
||||
{
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
MaxConnectionsPerServer = 100
|
||||
})
|
||||
{
|
||||
DefaultRequestHeaders = { { "User-Agent", "py_clob_client" }, { "Accept", "*/*" } }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte <c>master.key</c>) und aktiviert
|
||||
/// die at-rest-Verschlüsselung. Muss VOR jeder Credential-Entschlüsselung laufen.
|
||||
/// </summary>
|
||||
private static void ConfigureSecretProtection(TerminalLogger logger)
|
||||
{
|
||||
string? masterKey = Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY");
|
||||
if (string.IsNullOrWhiteSpace(masterKey))
|
||||
{
|
||||
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
|
||||
if (File.Exists(keyFile)) masterKey = File.ReadAllText(keyFile).Trim();
|
||||
}
|
||||
SecretProtection.Configure(masterKey);
|
||||
|
||||
if (SecretProtection.IsConfigured)
|
||||
logger.Info("🔐 Secret-Verschlüsselung aktiv – Account-Credentials werden at-rest verschlüsselt (AES-256-GCM).");
|
||||
else
|
||||
logger.Warning("⚠️ SICHERHEIT: Kein POLYTRADER_MASTER_KEY gesetzt – Account-Credentials liegen UNVERSCHLÜSSELT in der DB.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user