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)); // D-2: Lizenz-CLI-Schalter (--license-status/--license-set-key/--license-deactivate) // laufen VOR dem Host-Aufbau und beenden sich selbst - kein Sinn, dafür Trading-Dienste // hochzufahren. if (Licensing.LicenseCli.TryHandle(args, out int cliExitCode)) return cliExitCode; var dcOptions = LoadDeploymentcenterOptions(); // Konstruktionsprüfung (--smoke-ui) testet Fensteraufbau, keine Lizenzlogik - dort // bewusst ALLE Module registrieren, sonst würde die Prüfung auf einem Build-Server // (nie lizenziert) still einen Großteil ihrer Abdeckung verlieren. var host = BuildHost(dcOptions, enforceLicense: !smoke, 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(); /// /// Lädt NUR den Deploymentcenter-Konfigurationsblock, bevor der Generic Host existiert – /// die Lizenzprüfung (D-2) muss VOR der Modul-Liste laufen, also bevor /// seine eigene baut. /// Liest dieselben Dateien wie der Host später (appsettings.json + appsettings.Local.json), /// nur eigenständig und minimal. Von mitbenutzt. /// internal static DeploymentcenterOptions LoadDeploymentcenterOptions() { var config = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: true) .AddJsonFile("appsettings.Local.json", optional: true) .Build(); var options = new DeploymentcenterOptions(); config.GetSection(DeploymentcenterOptions.SectionName).Bind(options); return options; } /// /// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die /// aktiven Module. Kennt die Oberfläche nicht. /// /// /// D-2: Wenn false (nur --smoke-ui), werden immer alle Module registriert – /// die Konstruktionsprüfung testet Fensteraufbau, keine Lizenzlogik. /// private static IHost BuildHost(DeploymentcenterOptions dcOptions, bool enforceLicense, 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); // D-2: ohne gültige Lizenz startet nur die Core-Shell (Terminal, Einstellungen) - alle // Trading-/Analyse-Module bleiben unregistriert. Kein Environment.Exit (Entscheidung // 31.07.2026): der Schlüssel lässt sich im Einstellungsfenster nachtragen. bool licensed = !enforceLicense || Licensing.LicenseGate.CheckUsable(dcOptions, serverSettings, logger); if (!licensed) { foreach (var m in allModules) disabled.Add(m.Name); } 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) => { services.AddSingleton(dcOptions); 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 => { var watchdog = new WatchdogHeartbeatService(sp.GetRequiredService()); // D-1: erster "checks"-Eintrag - beweist die Mechanik mit einem real // verfügbaren Signal. db/market_feed folgen, sobald die jeweiligen Dienste // ein Gesundheitssignal nach außen geben (siehe // docs/archiv/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, D-1). var vpn = sp.GetRequiredService(); var serverSettings = sp.GetRequiredService(); watchdog.RegisterCheck("vpn", () => serverSettings.VpnEnabled ? (vpn.IsConnected, vpn.IsConnected ? null : "Mullvad VPN nicht verbunden") : (true, null)); // VPN bewusst aus -> kein Fehlalarm return watchdog; }); // 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); } // D-3: Fehler-Reporting - rein ereignisgesteuert (AppDomain.UnhandledException, // TaskScheduler.UnobservedTaskException, TerminalLogger-Fehler), kein eigener Takt. services.AddSingleton(sp => new DeploymentcenterErrorReporter( sp.GetRequiredService(), sp.GetRequiredService())); services.AddHostedService(); services.AddHostedService(sp => sp.GetRequiredService()); 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: Seiten-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}"); } } // Die Startseite steckt seit der Einfenster-Shell in der Schleife oben (core.start); // separat geprueft wird nur noch das Shell-Fenster selbst. try { _ = new Views.ShellWindow(uiHost, host.Services); Console.WriteLine("[OK] ShellWindow konstruiert"); } catch (Exception ex) { failures++; Console.WriteLine($"[FEHLER] ShellWindow: {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 = { "AppBgBrush", "AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush", "AppBorderBrush", "AppDividerBrush", "AppTextBrush", "AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush", "AppAccentBrush", "AppAccentAltBrush", "AppAccentStrongBrush", "AppAccentSoftBrush", "AppPositiveBrush", "AppNegativeBrush", "AppWarningBrush", "AppNotifyWarnBrush", "AppNotifyErrorBrush", "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, sonst wird /// einer erzeugt – D-5/D-10) und aktiviert die at-rest-Verschlüsselung. Muss VOR jeder /// Credential-Entschlüsselung laufen. /// private static void ConfigureSecretProtection(TerminalLogger logger) { string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key"); string? masterKey = MasterKeyResolver.Resolve( keyFile, () => Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"), onGenerated: _ => logger.Warning( "🔐 Kein Master-Key gefunden – ein neuer wurde erzeugt und in \"master.key\" " + "gespeichert (App-Ordner, gitignored). WICHTIG: Diese Datei SOFORT separat sichern " + "(Passwort-Manager/Offline-Backup) – ihr Verlust macht jedes ab jetzt verschlüsselte " + "Secret unwiederbringlich unlesbar."), onWarning: msg => logger.Warning($"⚠️ {msg}")); 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."); } } }