diff --git a/PolyTraderSharp.sln b/PolyTraderSharp.sln index 746a137..aa080cc 100644 --- a/PolyTraderSharp.sln +++ b/PolyTraderSharp.sln @@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Modules.Supervis EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Modules.Accounting", "src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj", "{A74EB53F-08E7-43A5-A664-A49E9AB50AEE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.App.Avalonia", "src\PolyTrader.App.Avalonia\PolyTrader.App.Avalonia.csproj", "{C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,6 +117,18 @@ Global {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Release|x64.Build.0 = Release|Any CPU {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Release|x86.ActiveCfg = Release|Any CPU {A74EB53F-08E7-43A5-A664-A49E9AB50AEE}.Release|x86.Build.0 = Release|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Debug|x64.ActiveCfg = Debug|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Debug|x64.Build.0 = Debug|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Debug|x86.ActiveCfg = Debug|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Debug|x86.Build.0 = Debug|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Release|Any CPU.Build.0 = Release|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Release|x64.ActiveCfg = Release|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Release|x64.Build.0 = Release|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Release|x86.ActiveCfg = Release|Any CPU + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -126,6 +140,7 @@ Global {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {63F8B9B4-6F56-4A6B-BEBE-53DE49F913D1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {A74EB53F-08E7-43A5-A664-A49E9AB50AEE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C4D9F31B-9FC5-4BC6-98FF-55AE7A6BF23A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {60AA6BCF-B17E-4D52-A290-14154A3E97CF} diff --git a/Program.cs b/Program.cs index 71a43f6..3fdfaed 100644 --- a/Program.cs +++ b/Program.cs @@ -159,7 +159,7 @@ internal static class Program // MUSS als erster HostedService laufen: hydriert den State, bevor die // Trading-Services gegen einen leeren State anlaufen. - services.AddHostedService(); + services.AddHostedService(); // Module registrieren ihre eigenen Services/Channels/State selbst. foreach (var module in modules) @@ -460,7 +460,8 @@ internal static class Program services.AddCorePersistence(databaseOptions); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); foreach (var module in modules) { @@ -478,7 +479,9 @@ internal static class Program // State aus MySQL laden (Accounts/Trader/Settings), ohne die BackgroundServices zu starten. try { - host.Services.GetRequiredService() + host.Services.GetRequiredService() + .StartAsync(default).GetAwaiter().GetResult(); + host.Services.GetRequiredService() .StartAsync(default).GetAwaiter().GetResult(); } catch (Exception ex) diff --git a/Ui/Views/JobsView.cs b/Ui/Views/JobsView.cs index 2963ed6..dab6b4b 100644 --- a/Ui/Views/JobsView.cs +++ b/Ui/Views/JobsView.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using System.Windows.Forms; using PolyTraderSharp.Models; using PolyTraderSharp.Services; @@ -23,7 +23,7 @@ namespace PolyTraderSharp.Ui.Views public void Initialize(JobManager jobManager) { - dgvJobs.DataSource = jobManager.Jobs; + dgvJobs.DataSource = new System.ComponentModel.BindingList(jobManager.Jobs); } private void DgvJobs_CellContentClick(object? sender, DataGridViewCellEventArgs e) diff --git a/src/PolyTrader.App.Avalonia/.gitignore b/src/PolyTrader.App.Avalonia/.gitignore new file mode 100644 index 0000000..d4e20d2 --- /dev/null +++ b/src/PolyTrader.App.Avalonia/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +appsettings.Local.json +server_settings.xml +master.key +openrouter.key diff --git a/src/PolyTrader.App.Avalonia/App.axaml b/src/PolyTrader.App.Avalonia/App.axaml new file mode 100644 index 0000000..24b49b8 --- /dev/null +++ b/src/PolyTrader.App.Avalonia/App.axaml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PolyTrader.App.Avalonia/App.axaml.cs b/src/PolyTrader.App.Avalonia/App.axaml.cs new file mode 100644 index 0000000..a3fd30b --- /dev/null +++ b/src/PolyTrader.App.Avalonia/App.axaml.cs @@ -0,0 +1,49 @@ +using System; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.App.Avalonia.Shell; +using PolyTrader.App.Avalonia.Views; + +namespace PolyTrader.App.Avalonia +{ + /// + /// Avalonia-Anwendungsobjekt. Hält die Verbindung zwischen dem bereits laufenden Generic Host + /// (Trading-Services, Module, Persistenz) und der Oberfläche: beim Start wird der Launcher + /// erzeugt und die Views von Core und Modulen werden bei der Shell registriert. + /// + public partial class App : global::Avalonia.Application + { + /// + /// Wird von vor StartWithClassicDesktopLifetime gesetzt. + /// Bewusst statisch: Avalonia erzeugt die Application-Instanz selbst, ein Konstruktor- + /// Parameter ist daher nicht möglich. + /// + public static IServiceProvider? Services { get; set; } + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && Services != null) + { + var uiHost = Services.GetRequiredService(); + + CoreViews.Register(uiHost, Services); + foreach (var module in Services.GetServices()) + module.RegisterUi(uiHost, Services); + ViewIcons.AssignDefaults(uiHost); + + var launcher = new LauncherWindow(uiHost, Services); + uiHost.SetMainWindow(launcher); + desktop.MainWindow = launcher; + + // Schliessen des Launchers laeuft ueber die Sicherheitsabfrage (wie bisher das X). + desktop.ShutdownMode = global::Avalonia.Controls.ShutdownMode.OnMainWindowClose; + } + + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml b/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml new file mode 100644 index 0000000..b920ffb --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml.cs b/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml.cs new file mode 100644 index 0000000..71c981c --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using PolyTrader.App.Avalonia.Shell; +using PolyTrader.Core.Modularity; + +namespace PolyTrader.App.Avalonia.Controls +{ + /// + /// Code-Behind der Fenster-Menüleiste: nur Verdrahtung, kein Layout (siehe Layout-Regel). + /// Jedes Fenster ruft auf und bekommt damit dieselbe Leiste. + /// + public partial class WindowMenuBar : UserControl + { + private WindowMenuModel? _model; + + public WindowMenuBar() => AvaloniaXamlLoader.Load(this); + + /// + /// Verbindet die Leiste mit der Shell. ist null für den + /// Launcher (rechte Aktion „Beenden"), sonst die View-ID (rechte Aktion „Fenster schließen"). + /// Die Event-Registrierung wird beim Entladen wieder gelöst – kein Leak. + /// + public void Attach(IModuleUiHost host, string? currentViewId, Window owner) + { + _model = new WindowMenuModel(host, currentViewId, owner.Close); + DataContext = _model; + owner.Closed += (_, _) => _model?.Detach(); + } + } +} diff --git a/src/PolyTrader.App.Avalonia/PolyTrader.App.Avalonia.csproj b/src/PolyTrader.App.Avalonia/PolyTrader.App.Avalonia.csproj new file mode 100644 index 0000000..e083f9e --- /dev/null +++ b/src/PolyTrader.App.Avalonia/PolyTrader.App.Avalonia.csproj @@ -0,0 +1,50 @@ + + + + + net10.0 + Exe + enable + enable + PolyTrader.App.Avalonia + PolyTrader.App.Avalonia + app.manifest + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PolyTrader.App.Avalonia/Program.cs b/src/PolyTrader.App.Avalonia/Program.cs new file mode 100644 index 0000000..cbf56f9 --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Program.cs @@ -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 +{ + /// + /// 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; + + ConfigureSecretProtection(logger); + + 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; + + var uiHost = host.Services.GetRequiredService(); + CoreViews.Register(uiHost, host.Services); + foreach (var module in host.Services.GetServices()) + 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; + } + + /// + /// 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."); + } + } +} diff --git a/src/PolyTrader.App.Avalonia/Shell/AvaloniaUiHost.cs b/src/PolyTrader.App.Avalonia/Shell/AvaloniaUiHost.cs new file mode 100644 index 0000000..56dd856 --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Shell/AvaloniaUiHost.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using PolyTrader.Core.Modularity; + +namespace PolyTrader.App.Avalonia.Shell +{ + /// + /// Avalonia-Umsetzung von . Verhält sich wie der frühere + /// WinForms-ShellUiHost: je View höchstens ein Fenster, ein bereits offenes wird nach vorn + /// geholt, alle Fenster öffnen maximiert. + /// + /// Der Core-Contract ist toolkit-neutral ( liefert + /// ) – hier wird auf gecastet. Ein falscher Typ ist ein + /// Programmierfehler und soll laut scheitern, nicht still ein leeres Fenster erzeugen. + /// + public sealed class AvaloniaUiHost : IModuleUiHost + { + private readonly List _views = new(); + private readonly Dictionary _open = new(); + private Window? _mainWindow; + private bool _shutdownDialogOpen; + + /// True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde. + public bool ShutdownConfirmed { get; private set; } + + public event Action? OpenStateChanged; + + public IReadOnlyList Views => _views; + + public void RegisterView(ModuleView view) => _views.Add(view); + + /// Setzt das Hauptfenster (Launcher) – Ziel für . + public void SetMainWindow(Window main) => _mainWindow = main; + + public bool IsOpen(string viewId) => _open.ContainsKey(viewId); + + public void ActivateMain() + { + if (_mainWindow == null) return; + if (_mainWindow.WindowState == WindowState.Minimized) + _mainWindow.WindowState = WindowState.Maximized; + _mainWindow.Activate(); + } + + public void OpenView(string viewId) + { + var view = _views.FirstOrDefault(v => v.Id == viewId); + if (view != null) OpenView(view); + } + + public void OpenView(ModuleView view) + { + if (_open.TryGetValue(view.Id, out var existing)) + { + if (existing.WindowState == WindowState.Minimized) + existing.WindowState = WindowState.Normal; + existing.Activate(); + return; + } + + var window = (Window)view.CreateView(); + if (string.IsNullOrEmpty(window.Title)) window.Title = view.Title; + + // Vorgabe aus der WinForms-Oberflaeche uebernommen: alle Fenster maximiert (Full-HD-Ziel). + window.WindowStartupLocation = WindowStartupLocation.CenterScreen; + window.WindowState = WindowState.Maximized; + + _open[view.Id] = window; + window.Closed += (_, _) => + { + _open.Remove(view.Id); + OpenStateChanged?.Invoke(); + }; + + window.Show(); + OpenStateChanged?.Invoke(); + } + + /// + /// Zeigt die Sicherheitsabfrage (Beenden-Schaltfläche 10 s gesperrt) und fährt bei Bestätigung + /// herunter. Aus jedem Fenster aufrufbar – auch aus Modul-Fenstern, die die App nicht kennen. + /// + public async void RequestShutdown() + { + if (ShutdownConfirmed || _shutdownDialogOpen) return; + _shutdownDialogOpen = true; + try + { + var dlg = new Views.ShutdownConfirmWindow(); + bool confirmed = _mainWindow != null + ? await dlg.ShowDialog(_mainWindow) + : await dlg.ShowDialog(new Window()); + if (!confirmed) return; + + ShutdownConfirmed = true; + if (global::Avalonia.Application.Current?.ApplicationLifetime + is global::Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(); + } + } + finally + { + _shutdownDialogOpen = false; + } + } + } +} diff --git a/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs b/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs new file mode 100644 index 0000000..f3219e6 --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.Core.Modularity; +using PolyTraderSharp.Services; + +namespace PolyTrader.App.Avalonia.Shell +{ + /// + /// Registriert die Fenster, die der Core selbst beisteuert (Dashboard, Einstellungen, + /// Terminal, Jobs). Gegenstück zur entsprechenden Registrierung im WinForms-Program.cs – + /// die View-IDs sind bewusst identisch, damit Symbole und Reihenfolge unverändert greifen. + /// + public static class CoreViews + { + public static void Register(IModuleUiHost host, IServiceProvider services) + { + host.RegisterView(new ModuleView + { + Id = "core.jobs", + Title = "Server Jobs", + Group = "Core", + Order = 30, + CreateView = () => new Views.JobsWindow(host, services.GetRequiredService()) + }); + + // TODO Portierung: core.dashboard (Order 10), core.settings (Order 20) und + // core.terminal (Order 40) folgen. Aufbau siehe docs/UI-SPEZIFIKATION-WinForms.md, + // Originalcode im Git-Tag winforms-final. + } + } +} diff --git a/src/PolyTrader.App.Avalonia/Shell/ViewIcons.cs b/src/PolyTrader.App.Avalonia/Shell/ViewIcons.cs new file mode 100644 index 0000000..b5587f7 --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Shell/ViewIcons.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using PolyTrader.Core.Modularity; + +namespace PolyTrader.App.Avalonia.Shell +{ + /// + /// Löst die toolkit-neutralen -Schlüssel gegen die + /// Avalonia-Bildressourcen auf. Gegenstück zum gleichnamigen Helfer der WinForms-Shell – + /// dieselben Schlüssel, dieselben PNG-Dateien, sodass Core und Module unverändert bleiben. + /// + public static class ViewIcons + { + /// Symbol-Schlüssel → Dateiname in Resources/ (als Avalonia-Asset eingebettet). + private static readonly Dictionary FileByKey = new() + { + ["dashboard"] = "dashboard.png", + ["settings"] = "setting_tools.png", + ["terminal"] = "error_log.png", + ["jobs"] = "system_time.png", + ["accounting"] = "coins_in_hand.png", + ["copytrading"] = "cross_reference.png", + ["resolutionfarming"] = "file_start_workflow.png", + ["supervisor"] = "emotion_batman.png", + }; + + /// Standard-Symbolschlüssel je View-ID – identisch zur WinForms-Shell. + private static readonly Dictionary DefaultKeyByViewId = new() + { + ["core.dashboard"] = "dashboard", + ["core.settings"] = "settings", + ["core.terminal"] = "terminal", + ["core.jobs"] = "jobs", + ["accounting.main"] = "accounting", + ["copytrading.main"] = "copytrading", + ["resolutionfarming.main"] = "resolutionfarming", + ["supervisor.main"] = "supervisor", + }; + + private static readonly ConcurrentDictionary Cache = new(); + + /// Bild zum Schlüssel, oder null (kein Symbol / Datei fehlt). + public static Bitmap? Resolve(string? iconKey) + { + if (iconKey == null || !FileByKey.TryGetValue(iconKey, out var file)) return null; + + return Cache.GetOrAdd(iconKey, _ => + { + try + { + var uri = new Uri($"avares://PolyTrader.App.Avalonia/Assets/{file}"); + using var stream = AssetLoader.Open(uri); + return new Bitmap(stream); + } + catch + { + // Ein fehlendes Symbol darf die Oberflaeche nie aufhalten – Menueeintrag bleibt Text-only. + return null; + } + }); + } + + /// Setzt bei allen registrierten Views den Standard-Symbolschlüssel, falls noch keiner gesetzt ist. + public static void AssignDefaults(IModuleUiHost uiHost) + { + foreach (var view in uiHost.Views) + if (view.IconKey == null && DefaultKeyByViewId.TryGetValue(view.Id, out var key)) + view.IconKey = key; + } + } +} diff --git a/src/PolyTrader.App.Avalonia/Shell/WindowMenuModel.cs b/src/PolyTrader.App.Avalonia/Shell/WindowMenuModel.cs new file mode 100644 index 0000000..39411da --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Shell/WindowMenuModel.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Windows.Input; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using PolyTrader.Core.Modularity; + +namespace PolyTrader.App.Avalonia.Shell +{ + /// Schlanker Befehl ohne MVVM-Fremdbibliothek. + public sealed class RelayCommand : ICommand + { + private readonly Action _execute; + public RelayCommand(Action execute) => _execute = execute; + public bool CanExecute(object? parameter) => true; + public void Execute(object? parameter) => _execute(); + public event EventHandler? CanExecuteChanged { add { } remove { } } + } + + /// Ein Eintrag der Fenster-Menüleiste (Launcher, eine View, oder die rechte Aktion). + public sealed class WindowMenuEntry + { + public string Title { get; init; } = ""; + public Bitmap? Icon { get; init; } + + /// Aktuelles Fenster – wird fett dargestellt. + public bool IsCurrent { get; init; } + + /// Fenster ist offen (oder das aktuelle) – bekommt einen Haken. + public bool IsChecked { get; init; } + + public ICommand Command { get; init; } = new RelayCommand(() => { }); + + // Von den Bindungen im XAML genutzt (kein Konverter nötig). + public string FontWeight => IsCurrent ? "Bold" : "Normal"; + public string CheckMark => IsChecked ? "✓ " : ""; + } + + /// + /// Datenmodell der gemeinsamen Fenster-Menüleiste, die auf JEDEM PolyTrader-Fenster erscheint. + /// + /// Umsetzung der neuen Layout-Regel: Die WinForms-Fassung baute das Menü zur Laufzeit + /// auf (menu.Items.Clear() + dynamisch erzeugte ToolStripMenuItem). Hier liefert das + /// Modell nur noch ; das Layout steht deklarativ in + /// Controls/WindowMenuBar.axaml. + /// + public sealed class WindowMenuModel : INotifyPropertyChanged + { + private readonly IModuleUiHost _host; + private readonly string? _currentViewId; + private readonly Action _closeThisWindow; + + public ObservableCollection Entries { get; } = new(); + + /// + /// null für den Launcher (dort heisst die rechte Aktion „Beenden"), sonst die View-ID + /// des Fensters (dann „Fenster schließen"). + /// + public WindowMenuModel(IModuleUiHost host, string? currentViewId, Action closeThisWindow) + { + _host = host; + _currentViewId = currentViewId; + _closeThisWindow = closeThisWindow; + + Rebuild(); + _host.OpenStateChanged += OnOpenStateChanged; + } + + /// Registrierung lösen, wenn das Fenster geht – kein Event-Leak über die Host-Lebensdauer. + public void Detach() => _host.OpenStateChanged -= OnOpenStateChanged; + + private void OnOpenStateChanged() + { + if (Dispatcher.UIThread.CheckAccess()) Rebuild(); + else Dispatcher.UIThread.Post(Rebuild); + } + + private void Rebuild() + { + Entries.Clear(); + + Entries.Add(new WindowMenuEntry + { + Title = "Launcher", + IsCurrent = _currentViewId == null, + IsChecked = _currentViewId == null, + Command = new RelayCommand(_host.ActivateMain) + }); + + foreach (var view in _host.Views.OrderBy(v => v.Order).ThenBy(v => v.Title)) + { + string id = view.Id; + bool isCurrent = id == _currentViewId; + Entries.Add(new WindowMenuEntry + { + Title = view.Title, + Icon = ViewIcons.Resolve(view.IconKey), + IsCurrent = isCurrent, + IsChecked = isCurrent || _host.IsOpen(id), + Command = new RelayCommand(() => _host.OpenView(id)) + }); + } + + // Kontextabhaengige rechte Aktion – wie in der WinForms-Fassung: nur der Launcher darf die + // App beenden (mit Sicherheitsabfrage), jedes andere Fenster schliesst nur sich selbst. + Entries.Add(_currentViewId == null + ? new WindowMenuEntry { Title = "Beenden", Command = new RelayCommand(_host.RequestShutdown) } + : new WindowMenuEntry { Title = "Fenster schließen", Command = new RelayCommand(_closeThisWindow) }); + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Entries))); + } + + public event PropertyChangedEventHandler? PropertyChanged; + } +} diff --git a/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml b/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml new file mode 100644 index 0000000..45c4550 --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/PolyTrader.App.Avalonia/Views/LauncherWindow.axaml.cs b/src/PolyTrader.App.Avalonia/Views/LauncherWindow.axaml.cs new file mode 100644 index 0000000..450ba5e --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Views/LauncherWindow.axaml.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Threading; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.App.Avalonia.Shell; +using PolyTrader.Core.Modularity; +using PolyTraderSharp; + +namespace PolyTrader.App.Avalonia.Views +{ + /// + /// „Startleiste" der Anwendung – Gegenstück zur bisherigen LauncherForm. Öffnet die Fenster, + /// spiegelt deren Offen-Status und zeigt die Kernkennzahlen in der Statusleiste. + /// Layout vollständig in LauncherWindow.axaml. + /// + public partial class LauncherWindow : Window + { + private readonly AvaloniaUiHost _uiHost; + private readonly IServiceProvider _services; + private readonly TradingState _state; + private readonly DispatcherTimer _statusTimer = new() { Interval = TimeSpan.FromSeconds(1) }; + private readonly ObservableCollection _windowButtons = new(); + + public LauncherWindow() => AvaloniaXamlLoader.Load(this); + + public LauncherWindow(AvaloniaUiHost uiHost, IServiceProvider services) : this() + { + _uiHost = uiHost; + _services = services; + _state = services.GetRequiredService(); + + // currentViewId == null => dies ist der Launcher (rechte Menueaktion "Beenden"). + this.FindControl("menuBar")!.Attach(uiHost, null, this); + + this.FindControl("windowButtons")!.ItemsSource = _windowButtons; + RebuildWindowButtons(); + _uiHost.OpenStateChanged += OnOpenStateChanged; + + this.FindControl