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.");
}
}
}