using System; using System.Collections.Generic; using System.IO; using System.Linq; using Avalonia; using Avalonia.Controls; 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 { /// /// Einstiegspunkt der plattformneutralen PolyTrader-Anwendung. /// /// Der Aufbau ist bewusst zweigeteilt: stellt den Generic Host mit /// Persistenz, Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst /// hängt Avalonia daran. Damit lässt sich derselbe Host später ohne Umbau /// für den kopflosen Linux-Betrieb starten (Schalter --headless, Stufe L2). /// 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(); } } /// Von Avalonia (auch vom XAML-Previewer) erwartete Fabrikmethode. public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace(); /// /// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die /// aktiven Module. Kennt die Oberfläche nicht. /// private static IHost BuildHost(out TerminalLogger bootLog) { var serverSettings = ServerSettings.Load(ServerSettingsPath); var logger = new TerminalLogger(); bootLog = logger; // Anzeige-Kultur festnageln: Auf Linux richtet sich die Kultur sonst nach LANG/LC_ALL, // das unter systemd oft gar nicht gesetzt ist - dann faellt .NET auf Invariant zurueck // und aus "1.234,56 USDC" wird "1,234.56 USDC". Maschinen-I/O (API, Logs, CSV) laeuft // unabhaengig davon ohnehin invariant. var uiCulture = new System.Globalization.CultureInfo("de-DE"); System.Globalization.CultureInfo.DefaultThreadCurrentCulture = uiCulture; System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = uiCulture; ConfigureSecretProtection(logger); // Anzeige-Zeitzone einmalig setzen (Logs, Auswertungen). Ein unbekannter Wert ist nicht // fatal - es wird auf die Systemzeitzone ausgewichen und gewarnt. AppTimeZone.Configure(serverSettings.ApplicationTimeZoneId, m => logger.Warning(m)); logger.Info($"Anzeige-Zeitzone: {AppTimeZone.CurrentId}"); var allModules = new List { new CopyTradingModule(), new ResolutionFarmingModule(), new SupervisorModule(), new AccountingModule() }; var disabled = new HashSet(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(context.Configuration.GetSection(DatabaseOptions.SectionName)); services.AddCorePersistence(databaseOptions); services.AddSingleton(serverSettings); services.AddSingleton(logger); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddHttpClient(); // Polymarket-Zugriffe teilen sich einen langlebigen Verbindungspool. services.AddSingleton(sp => new PolymarketApiService( sp.GetRequiredService(), CreatePolymarketHttpClient())); services.AddSingleton(sp => new PolymarketClobClient( sp.GetRequiredService(), CreatePolymarketHttpClient())); // Benachrichtigungen: neutrale Senke, bis RocketChat/Telegram angebunden sind. services.AddSingleton( sp => new LogNotificationSink(sp.GetRequiredService())); services.AddSingleton(sp => new WatchdogHeartbeatService(sp.GetRequiredService())); // MUSS vor den Modulen registriert werden: hydriert die Accounts, bevor die // Trading-Dienste anlaufen (Reihenfolge = Registrierungsreihenfolge). services.AddHostedService(); foreach (var module in modules) { services.AddSingleton(module); module.RegisterServices(services, context.Configuration); } services.AddHostedService(); services.AddHostedService(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); // Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos. services.AddSingleton(); }) .Build(); } /// /// Kopflose Konstruktionsprüfung aller registrierten Fenster – Nachfolger des /// --smoke-ui-Laufs der WinForms-App, der Konstruktionsfehler zuverlässig gefangen hat. /// Läuft ohne Fenster und ohne Message-Loop. /// private static int RunSmokeUi(IHost host, TerminalLogger logger) { // Avalonia muss initialisiert sein, damit Fenster konstruiert werden koennen. BuildAvaloniaApp().SetupWithoutStarting(); App.Services = host.Services; ThemeManager.Initialize(host.Services.GetRequiredService(), ServerSettingsPath, logger); var uiHost = host.Services.GetRequiredService(); CoreViews.Register(uiHost, host.Services); foreach (var module in host.Services.GetServices()) module.RegisterUi(uiHost, host.Services); ModuleViews.Register(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}"); } // Der Einstellungs-Editor wird aus den Attributen des Modells aufgebaut. Ein Fenster kann // fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen - // deshalb hier gegen die tatsaechliche Feldzahl pruefen. try { var sections = ViewModels.SettingsModelBuilder.Build(new ServerSettings()); int fieldCount = sections.Sum(x => x.Fields.Count); if (sections.Count == 0 || fieldCount == 0) { failures++; Console.WriteLine("[FEHLER] Einstellungs-Editor: keine Felder aus ServerSettings ermittelt " + "(Category-/DisplayName-Attribute verloren?)."); } else { Console.WriteLine($"[OK] Einstellungs-Editor: {sections.Count} Abschnitte, {fieldCount} Felder " + $"({string.Join(", ", sections.Select(x => x.Title))})"); } } catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] Einstellungs-Editor: {ex.GetType().Name}: {ex.Message}"); } // Farbschema: jeder Token muss in BEIDEN Varianten aufloesen. Ein Tippfehler im // Schluessel faellt sonst nicht auf - ThemeManager.Brush liefert dann still Grau. string[] themeKeys = { "AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush", "AppBorderBrush", "AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush", "AppPositiveBrush", "AppNegativeBrush", "AppWarningBrush", "AppTradeLossBrush", "AppTradeSmallWinBrush", "AppTradeBigWinBrush", "AppToggleActiveBrush", "AppToggleSellOnlyBrush", "AppToggleInactiveBrush", "AppChatUserBrush", "AppChatAgentBrush" }; foreach (var variant in new[] { global::Avalonia.Styling.ThemeVariant.Light, global::Avalonia.Styling.ThemeVariant.Dark }) { var missing = themeKeys.Where(k => !(global::Avalonia.Application.Current!.TryFindResource(k, variant, out object? v) && v is global::Avalonia.Media.IBrush)) .ToList(); if (missing.Count > 0) { failures++; Console.WriteLine($"[FEHLER] Farbschema „{variant}“: fehlende Farben: {string.Join(", ", missing)}"); } else { Console.WriteLine($"[OK] Farbschema „{variant}“: alle {themeKeys.Length} Farben vorhanden"); } } Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ==="); return failures == 0 ? 0 : 1; } /// /// HttpClient für die Polymarket-APIs: langlebiger Verbindungspool und die Header, die die /// Gegenstelle erwartet (identisch zur bisherigen WinForms-Zusammenstellung). /// 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", "*/*" } } }; /// /// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte master.key) und aktiviert /// die at-rest-Verschlüsselung. Muss VOR jeder Credential-Entschlüsselung laufen. /// 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."); } } }