Farbschema hell / dunkel / dem System folgen, umschaltbar per Klick, Stand wird in server_settings.xml gemerkt (ServerSettings.Theme). Der Editor zeigt die Einstellung automatisch als Auswahlfeld - kein Zusatzcode, weil sie ein Enum ist. - App.axaml: 18 Farb-Token je Variante (Flaechen, Rahmen, Texte, Bedeutungsfarben, Zeilenfaerbung, Trading-Umschalter, Chat-Rollen). Die Standard-Steuerelemente stellt das FluentTheme selbst um. - 22 fest verdrahtete Farben in 11 XAML-Dateien auf DynamicResource umgestellt - die wechseln damit von selbst mit. - Umschalter (Sonne/Mond) sitzt in der gemeinsamen Fensterleiste, also auf JEDEM Fenster. Der eigentliche Knackpunkt waren die Farben, die im Code gesetzt werden - die folgen dem Thema NICHT von selbst: - TradeRowPalette liefert jetzt Eigenschaften statt static readonly, loest also bei jedem Zugriff neu auf. Im Dunkeln gedaempfte Toene statt der hellen Pastelltoene, die dort blenden und den Text unlesbar machen wuerden. - ChatEntry speichert die ROLLE statt eines fertigen Brush - dadurch stimmt der Verlauf nach dem Umschalten ohne Neuaufbau der Liste. - Launcher-Umschalter, Dashboard-KPI und die Grid-Zeilenfarben zeichnen sich ueber ThemeManager.ThemeChanged neu. - Das Terminal bleibt in beiden Schemata dunkel (Konsolen sind konventionell dunkel). Nebenbei die Anzeige-Kultur gepinnt (stand ohnehin auf der Linux-Liste): auf Linux richtet sie sich sonst nach LANG/LC_ALL, das unter systemd oft nicht gesetzt ist - dann faellt .NET auf Invariant zurueck und aus '1.234,56 USDC' wird '1,234.56 USDC'. Maschinen-I/O laeuft davon unabhaengig weiter invariant. Smoke-UI prueft jetzt zusaetzlich, dass ALLE 18 Farben in BEIDEN Varianten aufloesen - ein vertippter Ressourcenschluessel wuerde sonst still grau werden. Verifiziert: Solution baut, 450 Tests gruen, --smoke-ui gruen (10 Fenster + Editor + beide Farbschemata), App startet im gespeicherten Schema, Linux-Publish laeuft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
15 KiB
C#
321 lines
15 KiB
C#
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
|
||
{
|
||
/// <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;
|
||
|
||
// 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<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;
|
||
ThemeManager.Initialize(host.Services.GetRequiredService<ServerSettings>(), ServerSettingsPath, logger);
|
||
|
||
var uiHost = host.Services.GetRequiredService<AvaloniaUiHost>();
|
||
CoreViews.Register(uiHost, host.Services);
|
||
foreach (var module in host.Services.GetServices<IPolyTraderModule>())
|
||
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;
|
||
}
|
||
|
||
/// <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.");
|
||
}
|
||
}
|
||
}
|