diff --git a/.gitignore b/.gitignore index 490b87e..dcaa3df 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ MongoDB/ master.key openrouter.key +# Packager-Zugangsdaten (FTP, updateservice:publish-Token) – Vorlage in deploy/packager.config.example.json +deploy/packager.config.json + # ── Logs & temporäre Dateien ───────────────────── *.log *.tmp diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..dd01794 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,23 @@ + + + + + 0.1.0 + + + diff --git a/NuGet.Config b/NuGet.Config index 6e49632..0e81abb 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -3,19 +3,23 @@ - + Neues SDK übernehmen: siehe lib/nuget/README.md. + LicenseLabrador.Client wird mit der Deploymentcenter-Integration abgelöst (siehe + docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, Schnitt D-6) und + erst nach der Live-Abnahme von Watchdog/Lizenz entfernt. --> - + + diff --git a/appsettings.json b/appsettings.json index 5e64f5a..20f0e5a 100644 --- a/appsettings.json +++ b/appsettings.json @@ -3,6 +3,20 @@ "_comment": "MySqlConnectionString liegt in der gitignorierten appsettings.Local.json.", "MySqlConnectionString": "" }, + "Deploymentcenter": { + "_comment": "Token und LicenseKey liegen in der gitignorierten appsettings.Local.json (Secrets gehören nicht in die versionierte Datei).", + "BaseUrl": "https://dc.mhdf.de", + "ProjectSlug": "polytrader", + "Channel": "prod", + "Token": "", + "LicenseKey": "", + "WatchdogSource": "polytrader", + "WatchdogInstance": "prod-1", + "WatchdogIntervalSeconds": 60, + "WatchdogEnabled": false, + "ErrorReportingEnabled": false, + "UpdateCheckEnabled": false + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/deploy/packager.config.example.json b/deploy/packager.config.example.json new file mode 100644 index 0000000..39503d3 --- /dev/null +++ b/deploy/packager.config.example.json @@ -0,0 +1,40 @@ +{ + "_comment": "Kopie als packager.config.json in diesem Ordner anlegen und ausfuellen. packager.config.json ist per .gitignore ausgeschlossen. Alternativ ueber Umgebungsvariablen: DC_FTP_HOST, DC_FTP_PORT, DC_FTP_USER, DC_FTP_PASS, DC_API_URL, DC_TOKEN. Siehe docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, Schnitt D-4.", + + "ftpHost": "ftp.example.com", + "ftpPort": 21, + "ftpUser": "ftp-user", + "ftpPass": "ftp-password", + "ftpRemoteBaseDir": "/public_html/releases", + + "apiBaseUrl": "https://dc.mhdf.de", + "_apiToken_comment": "Token mit dem Recht updateservice:publish. Im Deploymentcenter-WebUI unter Token-Verwaltung erzeugen - NICHT das Dev-Sub-Token aus appsettings.Local.json wiederverwenden (das hat nur watchdog:ping/bugtracker:report).", + "apiToken": "", + + "_excludePatterns_comment": "Kommt gar nicht ins Paket. D-6 des Umsetzungsplans: appsettings.Local.json enthaelt das MySQL-Passwort im Klartext, server_settings.xml den Watchdog-Token und den Lizenzschluessel, master.key den AES-Master-Key aller verschluesselten Secrets. Der Packager bricht seit Version 2.5.0 zusaetzlich bei erkannten Zugangsdaten ab (Datei- UND Inhaltspruefung) - diese Liste ist die erste, nicht die einzige Verteidigungslinie.", + "excludePatterns": [ + "appsettings.Local.json", + "appsettings.*.Local.json", + "*.local.json", + "master.key", + "openrouter.key", + "server_settings.xml", + "*.pfx", + "*.key", + "*.pem", + "*.p12", + "*.db", + "*.sqlite", + "*.sqlite3", + "Logs/**", + "*.pdb", + "*.xml", + "*.log", + "scratch/**" + ], + + "_preservePatterns_comment": "Wird ausgeliefert, ersetzt am Ziel aber niemals eine vorhandene Datei. appsettings.json enthaelt den Deploymentcenter-Konfigurationsblock (BaseUrl, ProjectSlug, ...) - eine Erstinstallation bekommt die Vorlage, ein Update laesst die vor Ort eingerichteten Werte in Ruhe.", + "preservePatterns": [ + "appsettings.json" + ] +} diff --git a/deploy/polytrader.service b/deploy/polytrader.service new file mode 100644 index 0000000..06ddcf6 --- /dev/null +++ b/deploy/polytrader.service @@ -0,0 +1,39 @@ +# systemd-Unit für den kopflosen Linux-Betrieb (--headless, Stufe L2 der Linux-Portierung). +# Der Deploymentcenter-Installer legt selbst keinen Dienst an (siehe SETUP_INTEGRATION_GUIDE §7, +# "Dienstregistrierung fehlt") - diese Datei ist unser Ersatz dafür. +# +# Einrichtung: +# sudo cp deploy/polytrader.service /etc/systemd/system/polytrader.service +# sudo systemctl daemon-reload +# sudo systemctl enable --now polytrader +# +# WICHTIG (D-11 aus dem Umsetzungsplan): server_settings.xml wird relativ zum Arbeitsverzeichnis +# geladen, master.key dagegen relativ zur ausführbaren Datei (AppContext.BaseDirectory). Beides +# fällt nur zusammen, wenn WorkingDirectory exakt das Installationsverzeichnis ist - deshalb hier +# ausdrücklich gesetzt statt dem systemd-Standard (Root des Dienstbenutzers) überlassen. Eine +# abweichende WorkingDirectory liest server_settings.xml aus dem falschen Ordner, ohne dass das +# irgendwo auffällt (ServerSettings.Load() legt bei fehlender Datei kommentarlos eine neue an). + +[Unit] +Description=PolyTrader (Trading- und Analyse-Suite fuer Polymarket) +After=network-online.target mysql.service +Wants=network-online.target + +[Service] +Type=simple +# Anpassen: Installationsverzeichnis und Dienstkonto. Nicht als root betreiben - das +# Installationsverzeichnis (inkl. master.key, server_settings.xml, data.db) gehört diesem Konto. +User=polytrader +Group=polytrader +WorkingDirectory=/opt/polytrader +ExecStart=/opt/polytrader/PolyTrader.App.Avalonia --headless +Restart=on-failure +RestartSec=10 + +# Absturz vom sauberen Beenden unterscheiden: SIGTERM statt SIGKILL, mit Frist fuer den +# geordneten Shutdown-Pfad (offene Positionen, Watchdog-"stopped_graceful", DB-Flush). +KillSignal=SIGTERM +TimeoutStopSec=45 + +[Install] +WantedBy=multi-user.target diff --git a/docs/ANALYSE-Linux-Portierung.md b/docs/ANALYSE-Linux-Portierung.md index 748e815..a6f34da 100644 --- a/docs/ANALYSE-Linux-Portierung.md +++ b/docs/ANALYSE-Linux-Portierung.md @@ -7,6 +7,15 @@ > **Lizenzverwaltung, Watchdog und Error-Reporting** grundlegend. Alles, was in diesem Dokument zu > LicenseLabrador oder zum Watchdog-Heartbeat steht, ist damit Übergangszustand — dort nichts mehr > ausbauen. +> +> **P3c hat seit dem 14.08.2026 einen eigenen Plan:** +> **[UMSETZUNGSPLAN-Deploymentcenter-Integration.md](./umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md)** +> — er ist ab jetzt maßgeblich und korrigiert zwei Aussagen dieses Dokuments: +> §5.2 unterschätzt die Watchdog-Umstellung („nur Header und URL"; tatsächlich kommen `version`, +> `os`, `checks`, `metrics` und `status: "stopped"` dazu), und §5.2 wie Risiko **R6** stützen die +> headless-Lizenzprüfung auf `ILicensePrompt` aus dem SDK — diese Schnittstelle wird dort jedoch +> **nirgends benutzt** und trägt nichts. Der Umfang liegt bei **4–7 PT** statt 2–4 PT, weil +> Error-Reporting, Auslieferung und Erstinstallation hier noch nicht enthalten waren. --- diff --git a/docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md b/docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md new file mode 100644 index 0000000..7218bed --- /dev/null +++ b/docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md @@ -0,0 +1,816 @@ +# Umsetzungsplan: Deploymentcenter in PolyTrader einbinden (P3c) + +> Stand: 14.08.2026 · Grundlage: `J:\Softwareprojekte\Deploymentcenter\docs` (Fassung 2.4) und die +> SDK-Quellen unter `client-dotnet\Deploymentcenter.Client`. +> Löst ab: [UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.md](./UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.md) +> (Watchdog + LicenseLabrador als Einzeldienste). +> Ersetzt in `ANALYSE-Linux-Portierung.md` den Punkt **P3c** und präzisiert dort §5.2. + +--- + +## 0. Was sich gegenüber der bisherigen Planung geändert hat + +Das Deploymentcenter ist nicht mehr „Lizenz + Watchdog an einem Ort", sondern deckt inzwischen +**vier** Bereiche ab. Zwei davon standen in unserer Planung bisher gar nicht: + +| Bereich | Zustand bei uns | +|---|---| +| **Lizenz** | vorhanden, aber gegen LicenseLabrador gebaut (`LicenseGate`, `Ui/LicenseDialog`) — **und in der Avalonia-App gar nicht verdrahtet**, dort läuft derzeit alles ungeprüft | +| **Watchdog** | vorhanden (`WatchdogHeartbeatService`), aber gegen die **alte** Watchdog-API gebaut — URL, Header und Payload passen nicht | +| **Error-Reporting / Bugtracker** | **existiert bei uns nicht**. Es gibt im gesamten Repo weder `AppDomain.UnhandledException` noch `TaskScheduler.UnobservedTaskException` | +| **UpdateService + Erstinstallation** | **existiert bei uns nicht**. Kein Paket, kein Release-Weg, keine Versionsdisziplin (`` steht in keiner einzigen `.csproj`) | + +Die Schätzung aus der Analyse (2–4 PT) bezog sich auf die ersten beiden Punkte. Mit +Error-Reporting, Auslieferung und Erstinstallation liegt der Umfang bei **4–7 PT**. + +--- + +## 1. Befunde — Probleme, Bugs und Konzeptfehler + +Sortiert nach Wirkung auf uns. Spalte „Wo" trennt, was **wir** lösen und was im +**Deploymentcenter** gelöst werden müsste. + +### D-1 · Der Update-Weg beendet die Anwendung hart — für einen Trading-Bot unzulässig · **hoch** · Wo: wir + DC + +`UpdateClient.LaunchUpdateAgent` ruft `Environment.Exit(0)` +(`client-dotnet/Deploymentcenter.Client/UpdateClient.cs:401`), und `exitCurrentApp: true` ist die +**Vorgabe**. Genau das haben wir bei der Lizenz schon einmal ausgeschlossen: Eine Anwendung mit +offenen Positionen, laufenden CLOB-Aufrufen und einem 10-Sekunden-Bestätigungsdialog darf nicht +mitten im Lauf abgeschossen werden. + +Lösbar auf unserer Seite (`exitCurrentApp: false` und danach der bestehende geordnete +Shutdown-Pfad). **Nicht lösbar auf unserer Seite:** Der Agent wartet über `--wait-for-pid` auf das +Ende der Anwendung, aber nur `--wait-timeout` Sekunden — Vorgabe **60** +(`Deploymentcenter.UpdateAgent/Program.cs:70`). `LaunchUpdateAgent` hat für diesen Wert +**keinen Parameter** (`UpdateClient.cs:330-342`). Unser Herunterfahren nimmt sich allein für +`host.StopAsync` bis zu 30 s, dazu Avalonia-Teardown und die Modul-Stopps. Reißt die Frist, tut der +Agent **nichts** — immerhin sicher, aber ohne Rückmeldung und ohne Stellschraube. + +**Wunsch an DC:** `waitTimeoutSeconds` in `LaunchUpdateAgent` durchreichen. +**Bis dahin bei uns:** den Agenten selbst starten (eigener `ProcessStartInfo`) statt über +`LaunchUpdateAgent` — dann setzen wir `--wait-timeout` selbst. + +### D-2 · Der Lizenzschlüssel wandert in die Kommandozeile · **hoch** · Wo: DC + +Der Agent nimmt die Zugangsdaten der Release-Ablage ausschließlich als Argument entgegen: +`--license-key` bzw. `--download-user/--download-password` +(`Deploymentcenter.UpdateAgent/Program.cs:70-79`), und `UpdateClient.LaunchUpdateAgent` hängt sie +ebenso an (`UpdateClient.cs:366-369`). Umgebungsvariablen liest der Agent **an keiner Stelle**. + +Auf einem Mehrbenutzersystem zeigt `ps` (bzw. der Task-Manager) die Argumente jedes laufenden +Prozesses. Das ist wörtlich dieselbe Begründung, mit der `UPGRADE.md §5` den `curl`-Aufruf im +Crontab verwirft — an dieser Stelle wird sie nicht angewandt. Für uns kommt hinzu: „keine Secrets +in argv" ist bei PolyTrader ein bereits **umgesetzter** Sicherheitsbefund; diese Integration würde +ihn zurücknehmen. + +**Wunsch an DC:** Zugangsdaten zusätzlich über `DC_LICENSE_KEY` / `DC_DOWNLOAD_PASSWORD` oder über +stdin annehmen. Sauberer Workaround auf unserer Seite gibt es nicht. + +### D-3 · Das Lizenzurteil ist nicht signiert — Rückschritt gegenüber LicenseLabrador · **mittel–hoch** · Wo: DC + +`LicenseClient` liest den Status aus reinem JSON (`LicenseClient.cs:191-237`). Der mitgeschickte +`nonce` (`:113`) wird in der Antwort **nie geprüft**, eine Signatur gibt es nicht. LicenseLabrador +verifizierte die Serverantwort per Ed25519 gegen einen einkompilierten Public Key und reflektierte +die Nonce — deshalb konnte niemand ohne den privaten Schlüssel ein „valid" erfinden. + +Jetzt trägt allein TLS. Wer DNS oder die `hosts`-Datei umbiegt und ein eigenes Zertifikat in den +Trust Store legt — also der Besitzer der Maschine — kann sich ein `{"status":"valid"}` selbst +ausstellen. Für unser erklärtes Bedrohungsmodell („Schutz gegen einfaches Kopieren auf ein fremdes +System") reicht das; sobald Dritte die Software bekommen, ist es die schwächste Stelle der Kette. + +Bemerkenswert: Für **Release-Pakete** führt `UPDATESERVICE_INTEGRATION_GUIDE §6` eine ausführliche +und richtige Begründung, warum dort asymmetrisch signiert werden muss („ein Update wird auf dem +Zielsystem geprüft") — beim Lizenzurteil gilt dasselbe Argument und fehlt die Umsetzung. + +Zusatz: Der Client erzwingt **kein** `https://` (`serverBaseUrl` wird ungeprüft übernommen, +`LicenseClient.cs:118`). Das prüfen wir wie beim Heartbeat selbst. + +### D-4 · `ILicensePrompt` ist eine tote Naht · **mittel** · Wo: DC (Doku) + unsere Analyse + +`LicenseClient` nimmt ein `ILicensePrompt` entgegen und legt es in `_prompt` ab +(`LicenseClient.cs:62,69-72`) — **benutzt wird es nirgends**, im ganzen SDK gibt es keine weitere +Fundstelle. Unsere `ANALYSE-Linux-Portierung.md` (§5.2 und Risiko R6) baut die headless-Story +darauf auf, dass diese Abstraktion den Start auf einem Server ohne Display trägt. Sie tut nichts. + +Kein Beinbruch — die Ablauflogik gehört ohnehin zu uns (`LicenseGate` hat sie bereits) —, aber die +Analyse muss korrigiert werden, und die Abstraktion bauen wir selbst. + +### D-5 · Reihenfolgefalle beim Zugangsschutz trifft uns in der scharfen Variante · **mittel** · Wo: wir + +`UPGRADE.md §16.1` beschreibt für bestehende Produkte „erst ausliefern, dann scharfschalten". Für +**neue** Produkte gilt das ausdrücklich nicht: `/releases/polytrader/` entsteht erst mit dem ersten +Upload und wird beim nächsten `cli/tick.php`-Lauf geschützt. Es gibt also kein Zeitfenster, in dem +sich ein noch ungeschütztes Paket ziehen ließe. + +**Der allererste ausgelieferte Build muss die Zugangsdaten bereits mitbringen.** Veröffentlichen wir +zuerst ein Release, dessen Anwendung `credentials` noch nicht übergibt, fällt die Tür hinter ihm zu +und jede Erstinstallation bekommt 401. + +Zweitens: Der Schutz beruht auf `.htaccess` und trägt nur unter Apache mit `AllowOverride`. Die +Anzeige „GESCHÜTZT" beweist nichts — nur der **Selbsttest** tut es. Gehört in unsere Abnahme, nicht +in die Zuversicht. + +### D-6 · Der Packager würde unsere Secrets mitveröffentlichen · **hoch** · Wo: wir + +`pack-and-deploy` verpackt das Publish-Verzeichnis. Bei uns liegt dort: + +| Datei | Inhalt | Wie sie dorthin kommt | +|---|---|---| +| `appsettings.Local.json` | **MySQL-Verbindung mit Passwort** | `CopyToOutputDirectory` in `PolyTrader.App.Avalonia.csproj` | +| `master.key` | AES-Master-Key aller at-rest-Secrets | wird neben der Anwendung erwartet (`Program.cs:309`) | +| `openrouter.key`, `server_settings.xml`, `data.db`, `Logs/` | API-Schlüssel, Watchdog-Token, Lizenzschlüssel, Betriebsdaten | Betrieb | + +Ohne strikte `excludePatterns` landet all das im Paket und damit auf dem Server. Der Zugangsschutz +macht es nicht harmlos: `UPDATESERVICE §5A/Grenzen` sagt es selbst — dann ist es eben statt +öffentlich „für jeden Lizenznehmer sichtbar". Der Packager warnt inzwischen bei +zugangsdatenverdächtigen Dateien, aber die Warnung ist **keine Sperre**. + +Gegenmaßnahme: eigenes Publish-Profil in ein sauberes Verzeichnis, explizite `excludePatterns`, +und als Abnahmekriterium ein Blick in das fertige Archiv. + +### D-7 · `BuildInfo.targets` kommt bei unserer Auslieferungsart nicht mit · **mittel** · Wo: DC + wir + +`Deploymentcenter.Client.csproj` setzt weder `PackageId` noch `Version` und packt die +`Deploymentcenter.BuildInfo.targets` **nicht** ins NuGet-Paket (kein +``). Der Guide empfiehlt stattdessen einen relativen +`` ins Nachbar-Repo — genau die Kopplung, die wir bei LicenseLabrador bewusst aufgegeben +haben (Begründung in `lib/nuget/README.md`: PolyTrader muss ohne das Schwester-Repo bauen). + +**Wunsch an DC:** `PackageId`/`Version` setzen und die Targets als `build/Deploymentcenter.Client.targets` +mitpacken — dann greift sie beim Paketverweis von selbst. +**Bis dahin bei uns:** die Datei einmalig nach `build/` kopieren, mit Herkunfts- und +Aktualisierungsvermerk. + +### D-8 · Wir haben keine Versionsdisziplin · **mittel** · Wo: wir + +In keiner `.csproj` steht ein ``, eine `Directory.Build.props` gibt es nicht. Damit trägt +jede Assembly `1.0.0.0`. + +Der Packager prüft `--version` gegen die Hauptassembly und bricht bei Abweichung ab — das ist gut +so, denn der Fehler dahinter ist übel: Wird `1.0.1` als `1.0.2` veröffentlicht, aktualisieren alle +Installationen, melden weiterhin `1.0.1`, halten das Release erneut für neu und aktualisieren bei +**jedem Start** wieder. Ohne zentrale Version ist ein Release entweder unmöglich oder gefährlich. +Muss **vor** dem ersten Paket stehen. + +### D-9 · `setup.json` kann unsere Konfiguration nicht schreiben · **mittel** · Wo: wir + +Der Installer beherrscht die Formate `json` und `env` (`SETUP_INTEGRATION_GUIDE §3/§4`). Unsere +Betriebseinstellungen liegen in `server_settings.xml` — eigenes XML-Format, das er nicht kennt. + +Konsequenz: Entweder verzichtet die Erstinstallation auf jede Vorbelegung, oder der +Deploymentcenter-Block (BaseUrl, Token, Kanal, Source, Lizenzschlüssel) zieht nach +`appsettings.json`. Zweiteres ist ohnehin sauberer — die Anwendung liest bereits +`appsettings.Local.json` über die `IConfiguration`, und nur so kann die Erstinstallation das +Anwendungstoken per `source: "provision"` selbst ziehen, ohne dass jemand ein Token abtippt. + +### D-10 · Die Erstinstallation kann den Master-Key nicht lösen · **mittel** · Wo: wir + +Der Installer schreibt ausdrücklich **Klartext** und verschlüsselt nichts +(`SETUP_INTEGRATION_GUIDE §3/Dateirechte`). Einen `POLYTRADER_MASTER_KEY` kann er nicht erzeugen. +Eine frisch installierte Instanz hätte also alle Secrets unverschlüsselt liegen, bis jemand von +Hand nachhilft — auf einem Zielsystem im Ausland der wahrscheinlichste Ort für „später". + +Optionen: (a) Master-Key als `type: "secret"`-Feld abfragen, (b) PolyTrader erzeugt beim ersten +Start selbst einen und legt ihn mit `0600` ab, (c) über die systemd-Unit als Umgebungsvariable. +**Empfehlung: (b) + (c)**, (a) wäre eine weitere Stelle, an der jemand ein Geheimnis abtippt. + +Positiv: Die im Guide geforderte Weiche „Entschlüsselung reicht Klartext unverändert durch" haben +wir bereits (`SecretProtection.Unprotect`). Der Guide warnt zu Recht, sie nicht eines Tages als +tote Altlast zu entfernen — sie gehört als **aktiv genutzt** dokumentiert. + +### D-11 · Pfad-Inkonsistenz, die erst beim Installieren auffällt · **klein** · Wo: wir + +`ServerSettings.Load("server_settings.xml")` ist **relativ zum Arbeitsverzeichnis**, `master.key` +wird dagegen aus `AppContext.BaseDirectory` gelesen (`Program.cs:309`). Solange man aus dem +Projektordner startet, fällt das nicht auf. Nach einer Installation nach `/opt/polytrader` mit +einer systemd-Unit, deren `WorkingDirectory` woanders zeigt, liest die Anwendung eine andere Datei, +als der Installer geschrieben hat. Vor D-6/L2 klären. + +### D-12 · Der bestehende Heartbeat passt nicht — mehr als „Header und URL" · **klein** · Wo: wir + +`WatchdogHeartbeatService` sendet an `{base}/api/heartbeat` mit `X-Watchdog-Key` +(`WatchdogHeartbeatService.cs:280,428`). Das Deploymentcenter erwartet +`{base}/api/watchdog/v1/ping` mit `Authorization: Bearer` (alternativ `X-Agent-Token`), Events +gehen nach `/api/watchdog/v1/event`. + +`ANALYSE-Linux-Portierung.md §5.2` nennt das „nur Header und Ziel-URL". Tatsächlich kommen dazu: +`version` (sonst lässt sich ein Ausfall keinem Rollout zuordnen), `os`, `group`, das ganze +`checks`-Konzept, `metrics` — und `status: "stopped"`, das die Fehlalarm-Vermeidung beim geordneten +Beenden übernimmt. Unser `metadata`-Feld liest der Server dagegen nicht; es entfällt. + +### D-13 · Error-Reporting braucht einen Handler, den es bei uns nicht gibt · **mittel** · Wo: wir + +Weder `AppDomain.CurrentDomain.UnhandledException` noch `TaskScheduler.UnobservedTaskException` +kommen im Repo vor. Beim Bau lauern zwei Fallen: + +1. **Rückkopplung.** Der Reporter meldet einen Fehlschlag über `TerminalLogger.Error` — was den + nächsten Report auslöst. Der Reporter darf seine eigenen Fehler niemals über den + eskalierenden Pfad melden. (DC drosselt bei 60 Reports/Minute/IP; darauf verlassen wir uns nicht.) +2. **Datenabfluss.** Unsere Fehlermeldungen enthalten Wallet-Adressen, Marktkennungen und Beträge, + der DC-Server steht beim Hoster. Das vorhandene `Sanitize()` des Heartbeats muss auch auf + Meldung **und Stacktrace** des Error-Reports laufen. + +### D-14 · Kein Update ohne gültige Lizenz — auch nicht das reparierende · **klein, bewusst** · Wo: Entscheidung + +Der Zugangsschutz koppelt Updates an eine gültige Lizenz. Eine Installation mit abgelaufener Lizenz +kann sich also nicht mehr selbst reparieren. Für eigene Systeme belanglos; sobald Kunden im Spiel +sind, ist es eine Entscheidung, keine Nebenwirkung. + +### D-16 · `environment` im Fehler-Report ist heimlich ein Auth-Gate · **klein–mittel, überraschend** · Wo: DC (Doku + Fehlermeldung) + +Beim Live-Test von D-3 lieferte `/api/errors/v1/report` mit einem funktionierenden, +`bugtracker:report`-berechtigten Token beharrlich `401 unauthorized: "Erwartet wird ein Token mit +dem Recht \"bugtracker:report\""` — obwohl der Token genau dieses Recht trug (per +`/api/tokens/v1/provision` selbst bestätigt). + +Ursache: `report.php` liest `environment` aus dem **Request-Body** und reicht es an +`ApiAuth::requireScope($db, 'bugtracker:report', $environment, false)` durch; +`TokenManager::validateToken` lehnt danach ab, wenn `token['environment'] !== 'all' && +token['environment'] !== $environment` (`TokenManager.php:317`). Unser Token war bei der +Provisionierung auf `environment: "development"` festgelegt, der Report schickte +`"environment": "production"` — Mismatch, Ablehnung. + +Zwei Dinge daran sind problematisch: + +1. **Kein Dokument erwähnt diese Kopplung.** `bugtracker.md §3` führt `environment` als reine + Metadatum-Angabe für `/report` (Bugtracker), nicht als Zugriffs-Gate. Für `/api/errors/v1/report` + steht es nirgends. Andere Endpunkte (`watchdog:ping`, `watchdog:read/evaluate`, + `license:deactivate`) übergeben `$environment` gar nicht erst an `requireScope` — die Kopplung + gilt also inkonsistent nur für diesen einen Endpunkt. +2. **Die Fehlermeldung nennt die falsche Ursache.** `401` mit `"Token mit dem Recht + bugtracker:report"` legt einen fehlenden Scope nahe. Der tatsächliche Grund (Environment- + Mismatch) taucht in der Antwort nirgends auf — von der Client-Seite aus ist das ohne Blick in den + Serverquelltext nicht diagnostizierbar. + +**Für uns gelöst:** Token mit `environment: "all"` neu provisioniert (`dc_sub_109c1a03…`), gilt für +jeden gemeldeten Umgebungswert. **Wunsch an DC:** entweder den wahren Grund in der Fehlerantwort +nennen (z.B. eigener Code `environment_mismatch`), oder die Kopplung ganz aus `/errors/v1/report` +entfernen und `environment` wie bei den anderen Endpunkten rein deskriptiv behandeln. + +### D-15 · `Unauthorized` wird nur im statischen Zweig erkannt · **klein** · Wo: DC + +`CheckForUpdateAsync` setzt `result.Unauthorized` nur, wenn die statische `latest.json` mit 401 +antwortet (`UpdateClient.cs:105-111`). Fehlt die `latest.json`, fällt die Prüfung auf die +**offene** API zurück — eine tote Lizenz sieht dann aus wie „kein Update vorhanden". Für die +Fehlersuche im Feld relevant. + +--- + +## 2. Leitentscheidungen + +1. **Das SDK kommt als lokales NuGet-Paket** in `lib/nuget`, wie schon `LicenseLabrador.Client` — + keine Projektreferenz ins Nachbar-Repo. Begründung unverändert: PolyTrader muss ohne das + Schwester-Repo bauen (Build-Server, Zielland-System). +2. **Der Heartbeat bleibt unsere eigene Implementierung.** Der Service ist erprobt (Live-Reload, + Entprellung, TLS-Zwang, No-Throw-Vertrag, 21 Tests); umgestellt werden Endpunkt, Auth und + Payload — nicht die Mechanik. +3. **Kein `Environment.Exit`, nirgends.** Weder bei ungültiger Lizenz (Entscheidung vom 31.07.2026) + noch beim Update (D-1). Ein Update läuft über denselben geordneten Weg wie das Beenden über die + Menüleiste, inklusive Bestätigungsdialog. +4. **Updates werden nie automatisch installiert.** Die Anwendung prüft beim Start, meldet im + Launcher und installiert erst auf ausdrückliche Bestätigung. Auch ein `is_critical`-Release + erzwingt nichts — offene Positionen schlagen jedes Sicherheitsargument. +5. **Revidiert bei der Umsetzung (14.08.2026):** Ursprünglich geplant war, dass der gesamte + Deploymentcenter-Block nach `appsettings.json` zieht. Für Watchdog-Felder (`WatchdogUrl`, + `WatchdogToken`, `WatchdogSource`, `WatchdogInstance`, `WatchdogIntervalSeconds`) blieb es bei + `server_settings.xml`/`ServerSettings` — die bestehende UI (maskierte Eingabe, Live-Reload) deckt + den Bedarf bereits ab, ein zweites Konfigurationssystem dafür wäre unnötig gewesen. Nur Werte + **ohne** bestehende UI-Heimat (ProjectSlug, Channel, Feature-Schalter für D-3/D-4) liegen in + `appsettings.json`/`DeploymentcenterOptions`; Secrets darin (Token, LicenseKey für D-2) in der + gitignorierten `appsettings.Local.json`, zusätzlich über `SecretProtection` verschlüsselbar wo + die bestehende Ablage (`ServerSettings.LicenseKey`) das schon leistet. +6. **Ausgeliefert wird ausschließlich `PolyTrader.App.Avalonia`**, für `win-x64` und `linux-x64` + (Entscheidung Richard, 14.08.2026: WinForms verschwindet demnächst vollständig, L5). + **Folge für diesen Plan:** Lizenzdialog und Startgate werden **nur noch in Avalonia** gebaut, + nicht in beiden Anwendungen. `Licensing/LicenseGate.cs` und `Ui/LicenseDialog.cs` im + WinForms-Projekt werden nicht portiert, sondern ersetzt und gelöscht. Das spart eine komplette + zweite Verdrahtung — und die WinForms-Fassung wäre ohnehin die einzige mit Lizenzprüfung + gewesen, während die tatsächlich benutzte Anwendung ungeprüft läuft. +7. **Watchdog und LicenseLabrador werden vollständig ausgebaut** (Entscheidung Richard, + 14.08.2026). Kein Parallelbetrieb, keine Rückfallebene: Nach D-1/D-2 gibt es genau einen + Gegenüber, das Deploymentcenter. Siehe Schnitt **D-6**. +8. **Ein Anwendungstoken je Installation**, per `source: "provision"` beim Setup gezogen und auf + `watchdog:ping` + `bugtracker:report` beschränkt. Ein Master-Token wird **nie** ausgeliefert. +9. **Produkt-Slug ist `polytrader`** (Entscheidung Richard, 14.08.2026). Das alte `pt` aus dem + LicenseLabrador-Admin entfällt mit diesem Umzug ersatzlos. +10. **Der Master-Key wird bei der Erstinstallation erzeugt** (Entscheidung Richard, 14.08.2026), + nicht abgefragt — Einzelheiten in **D-5**. + +--- + +## 3. Umsetzung in Schnitten + +### D-0 · Fundament (ohne Serverkontakt) ✅ ERLEDIGT (14.08.2026) + +Umgesetzt wie geplant: `Directory.Build.props` mit `0.1.0` für alle Projekte, +`Deploymentcenter.Client` 2.5.0 als lokales NuGet-Paket in `lib/nuget` (PackageReference in +`PolyTrader.Core.csproj`, `BuildInfo.targets` kommt jetzt automatisch über `build/` aus dem Paket – +D-7 war zu diesem Zeitpunkt bereits vom Deploymentcenter-Team behoben, keine manuelle Übernahme +nötig), `nuget.config`-Mapping um `Deploymentcenter.*` ergänzt, `DeploymentcenterOptions` + +`Deploymentcenter`-Sektion in `appsettings.json` (Secrets in `appsettings.Local.json`, gitignored). +`PolyTrader.Core.BuildInfo.Version` erzeugt sich korrekt bei jedem Build. LicenseLabrador-Paket +bewusst noch nicht entfernt (das ist D-6, erst nach Live-Abnahme). + +### D-0-Rest · Zwischenverifikation der Befunde (14.08.2026) + +Vor dem Start Richards Hinweis geprüft, dass die Befunde behoben sein sollten: **5 von 6 bestätigt +behoben** (Commit `1967b49`, Deploymentcenter-Team) – D-1 (Exit/`--wait-timeout`), D-2 (argv-Key, +jetzt `DC_LICENSE_KEY`-Umgebungsvariable), D-4 (`ILicensePrompt` jetzt über `EnsureLicensedAsync()` +genutzt), D-6 (Packager bricht bei Secrets ab statt nur zu warnen), D-7 (Paket jetzt `IsPackable`, +Version 2.5.0). Als Bonus zusätzlich behoben: D-15 (`Unauthorized` jetzt auch im API-Rückfallzweig +erkannt). **D-3 (unsigniertes Lizenzurteil) bleibt bestätigt offen** – vom Deploymentcenter-Team +selbst so benannt („bewusst nicht Teil dieses Commits"), deckt sich mit unserer Einordnung in §6. + +### D-1 · Watchdog auf die Deploymentcenter-API umstellen ✅ ERLEDIGT (14.08.2026) + +Umgesetzt in [WatchdogHeartbeatService.cs](../../src/PolyTrader.Core/Services/WatchdogHeartbeatService.cs). +Abweichung von der ursprünglichen Planung: **kein neues Konfigurationsschema.** `ServerSettings` +(XML, Live-Reload, maskierte Token-Eingabe – bereits erprobte Mechanik) bleibt die Quelle für +`WatchdogUrl`/`WatchdogToken`/`WatchdogSource`/`WatchdogInstance`/`WatchdogIntervalSeconds`; nur +ihre Bedeutung ändert sich (zeigen jetzt auf das Deploymentcenter statt auf watchdog.mhdf.de). +`DeploymentcenterOptions`/`appsettings.json` wäre hier ein zweites, unnötiges Konfigurationssystem +gewesen – die bestehende UI/Masking/Live-Reload-Maschinerie deckt genau denselben Bedarf ab. +`DeploymentcenterOptions` bleibt für D-2/D-3/D-4 reserviert (Werte ohne bestehende UI-Heimat: +ProjectSlug, Channel, Feature-Schalter). + +- Endpunkte `…/api/watchdog/v1/ping` und `…/api/watchdog/v1/event`, Header `Authorization: Bearer` + statt `X-Watchdog-Key`. `TryBuildEndpoint` nimmt jetzt einen relativen Pfad statt eines + Aktionsnamens (`PingPath`/`EventPath`-Konstanten). +- Payload ergänzt: `version` (aus `PolyTrader.Core.BuildInfo.Version`, nicht mehr aus der + Assembly-Version – stimmt jetzt mit `Directory.Build.props` überein), `os` + (`RuntimeInformation.OSDescription`). Das bisherige `metadata`-Feld entfällt (der Server liest es + nicht, siehe Befund D-12). +- **`checks`-Mechanik neu:** `RegisterCheck(name, Func<(bool ok, string? message)>)` / + `UnregisterCheck(name)` – Composition Root registriert die konkreten Prüfungen, der Dienst selbst + kennt ihre Bedeutung nicht. Eine werfende Prüfung meldet sich selbst als fehlgeschlagen statt den + Heartbeat zu verhindern. In `Program.cs` (Avalonia) verdrahtet: `vpn`-Prüfung gegen + `MullvadVpnService.IsConnected`. **`db`/`market_feed`-Prüfungen sind bewusst noch nicht verdrahtet** + – die jeweiligen Dienste geben aktuell kein Gesundheitssignal nach außen; das ist ein Fast-Follow, + kein offener Blocker. +- Beim Herunterfahren sendet `StopAsync` jetzt **zwei** Dinge (vorher nur das Event): einen + Heartbeat mit `status:"stopped"` (das ist, was der Evaluator tatsächlich auswertet) **und** + weiterhin das `stopped_graceful`-Event ins Ereignisprotokoll. +- Tests: 8 neue/geänderte Fälle (Endpunkte, Bearer-Header, `checks`-Payload inkl. Fehlerfall), + gesamt 454 Tests grün. + +**Live-Abnahme (14.08.2026, gegen den echten Server, Dev-Sub-Token):** +`POST /api/watchdog/v1/ping` mit dem neuen Payload → `state: "up"`, `app_version` korrekt übernommen. +`POST /api/watchdog/v1/event` (`stopped_graceful`) → angenommen. Erneuter Ping mit +`status:"stopped"` → Monitor wechselt zu `state: "stopped"`, `state_changed: true`. Test-Monitor +`polytrader-dev-probe` bleibt im Deploymentcenter stehen (Zustand `stopped`, alarmiert nicht). + +### D-2 · Lizenz auf das Deploymentcenter umstellen ✅ ERLEDIGT (15.08.2026) + +Umgesetzt in [Licensing/LicenseGate.cs](../../src/PolyTrader.App.Avalonia/Licensing/LicenseGate.cs), +[Licensing/LicenseCli.cs](../../src/PolyTrader.App.Avalonia/Licensing/LicenseCli.cs) und den neuen +Lizenz-Schaltflächen in [SettingsWindow.axaml(.cs)](../../src/PolyTrader.App.Avalonia/Views/SettingsWindow.axaml). +Zwei Abweichungen von der ursprünglichen Planung, beide vereinfachend: + +- **Kein eigenes `ILicensePrompt`, kein eigener modaler Lizenzdialog.** Für die GUI wird die + bereits vorhandene, erprobte Settings-Oberfläche wiederverwendet — dieselbe maskierte + Prompt/Save-Mechanik wie beim Watchdog-Token (`btnLicenseKey` = „Lizenzschlüssel setzen …", + speichert verschlüsselbar in `server_settings.xml`) plus ein neuer `btnCheckLicense` = „Lizenz + jetzt prüfen", der den gespeicherten Schlüssel gegen den Server validiert und Status + Hardware-ID + in einem Info-Dialog anzeigt. Ein zusätzliches, eigenes Dialogfenster nur für die Lizenz wäre eine + zweite, redundante Eingabe-Oberfläche gewesen. Für `--headless`/CLI genügt die vom SDK + mitgelieferte `ConsoleLicensePrompt` (wird hier gar nicht gebraucht, siehe nächster Punkt) — + eine eigene Fassung hätte keinen Mehrwert geboten. + Grund für den Verzicht auf einen echten Avalonia-Dialog **vor** dem Hauptfenster: Avalonias + Startreihenfolge (`AppBuilder.Setup()` darf pro Prozess nur einmal laufen) macht einen zweiten, + vorgeschalteten Dialog vor `StartWithClassicDesktopLifetime` unnötig fragil; ein im laufenden + Fenster erreichbarer Button ist die robustere Lösung und deckt denselben Bedarf. +- **Modulentscheidung läuft still, ohne Dialog, vor dem Host-Aufbau.** `LicenseGate.CheckUsable` + validiert den in `server_settings.xml` gespeicherten Schlüssel synchron (wie einst die + WinForms-Fassung) und entscheidet, welche Module überhaupt registriert werden – **nicht** über + `EnsureLicensedAsync`/`ILicensePrompt` des SDK (die interaktive Korrektur läuft über die + Settings-UI, s.o., nicht über eine erneute Abfrage an dieser Stelle). Eine per Dialog neu + eingetragene Lizenz wirkt für Module deshalb **erst beim nächsten Start** – bewusst, konsistent + mit der Entscheidung vom 31.07.2026 (keine Laufzeit-Revalidierung). + +- `DeploymentcenterOptions` wird jetzt **vor** dem Host-Aufbau eigenständig aus + `appsettings.json`/`appsettings.Local.json` geladen (`Program.LoadDeploymentcenterOptions`), weil + die Modulliste schon vor dem eigentlichen `IHost` feststehen muss. Dieselbe Instanz wird + anschließend auch reguär in die DI registriert. +- **`--smoke-ui` bewusst ausgenommen:** `enforceLicense: !smoke` — die Konstruktionsprüfung soll auf + einem (nie lizenzierten) Build-Server weiterhin ALLE Modul-Fenster testen, nicht nur die + Core-Shell. Verifiziert: `--smoke-ui` konstruiert weiterhin alle 4 Modul-Fenster plus das + Einstellungsfenster mit den neuen Lizenz-Feldern (7 statt 6 Settings-Abschnitte). +- TLS wird — anders als im SDK (Befund D-3) — im Gate selbst erzwungen, exakt wie beim Watchdog. +- CLI-Schalter `--license-status`, `--license-set-key `, `--license-deactivate` nach der + Konvention aus LICENSE_INTEGRATION_GUIDE §4. `--license-deactivate` verlangt `DC_SHARED_KEY` aus + der Umgebung (Administrationsrechner) und verweigert sich mit klarer Meldung ohne ihn — der + Shared Key gehört nicht in die ausgelieferte Anwendung. +- **Keine eigenen Unit-Tests angelegt** (wie schon bei der WinForms-Fassung dokumentiert + begründet): `PolyTrader.Tests` referenziert `PolyTrader.App.Avalonia` nicht, der Gate ist dünner + Leim über dem SDK. Abdeckung über `--smoke-ui` (Konstruktion) und die Live-Verifikation unten. + +**Live-Verifikation (15.08.2026, echter Server, Dev-Lizenz mit 9999 Aktivierungen von Richard):** + +``` +> PolyTrader.App.Avalonia.exe --license-set-key LLAB2-12345-67890-ABCDE-FGHIJ +valid: License is valid +Lizenz gültig und in server_settings.xml gespeichert (Klartext). + +> PolyTrader.App.Avalonia.exe --license-status +HWID v2: 2:win:a765bd47c2458fd518a7a1ad8a9d1d4b654c91085dca4667f33e7cad4427bf93 (machine-guid) +Produkt: polytrader +Server: https://dc.mhdf.de +Cache: valid +Offline noch bis: 2026-08-18 07:56:54Z +Gespeicherter Schlüssel (server_settings.xml): gesetzt (Klartext) +``` + +Damit ist derselbe Codepfad geprüft, den `LicenseGate.CheckUsable` beim Start benutzt +(`LicenseClient.ValidateAsync`) – nicht nur ein isolierter Testaufruf. Die volle GUI/`--headless` +wurde **bewusst nicht** gestartet, um keine echten Handelsverbindungen (Polymarket-WSS, +Alchemy-Listener) aus dieser Sitzung heraus zu öffnen; das bleibt Richards manuelle Abnahme. +`max_activations` war bereits großzügig gesetzt (Richards Hinweis), keine Admin-Änderung nötig. + +### D-3 · Fehler-Reporting ✅ ERLEDIGT (17.08.2026) + +Umgesetzt in [DeploymentcenterErrorReporter.cs](../../src/PolyTrader.Core/Services/DeploymentcenterErrorReporter.cs) +und [SecretRedactor.cs](../../src/PolyTrader.Core/Security/SecretRedactor.cs) (die +Secret-Muster-Prüfung aus `WatchdogHeartbeatService.Sanitize` wurde dorthin ausgelagert, damit +Heartbeat-Meldung — kurz, einzeilig — und Stacktrace — lang, mehrzeilig — dieselbe Musterprüfung, +aber unterschiedliche Kürzungsregeln bekommen, ohne Code zu verdoppeln). + +- Drei Quellen wie geplant: `AppDomain.UnhandledException` (`level: "fatal"`, synchron mit 3-s-Frist + gesendet, da der Prozess unmittelbar danach stirbt), `TaskScheduler.UnobservedTaskException` + (`"warning"`, `SetObserved()` verhindert einen Prozessabbruch durch die schon behandelte Ausnahme), + `TerminalLogger`-Fehler (`"error"`, Fire-and-forget wie beim Heartbeat). +- **No-Throw + keine Rückkopplung** wie gefordert: eigene Sendefehler laufen ausschließlich über + `TerminalLogger.Warning` (niemals `.Error` – sonst würde der Reporter sich selbst erneut auslösen, + da er genau auf `.Error` hört). +- Eigene Ratenbegrenzung: 20 Meldungen/Minute (gleitendes Fenster, kein Hintergrund-Timer), deutlich + unter dem Server-Limit von 60/Minute/IP – Absicht ist, lokal zu drosseln, bevor eine + Fehlerschleife den Server oder andere Dienste auf derselben IP überhaupt erreicht. +- `Sanitize` (Nachricht, ≤200 Zeichen, einzeilig) und die neue `SanitizeStackTrace` (≤4000 Zeichen, + **mehrzeilig bleibt erhalten** – ein einzeiliger Stacktrace wäre zum Debuggen wertlos) laufen + beide vor jedem Versand. +- **Korrektur gegenüber der Planung:** kein `client_ref`/Idempotenz-Schlüssel implementiert – anders + als beim Bugtracker-`/report` dedupliziert `/api/errors/v1/report` bereits serverseitig über einen + inhaltsbasierten Gruppenschlüssel (bestätigt im Live-Test unten, `occurrence_count` stieg ohne + jedes Zutun). Ein zusätzlicher `client_ref` hätte hier keinen Zweck gehabt. +- Registrierung als `BackgroundService` wie `WatchdogHeartbeatService` (rein ereignisgesteuert, + `ExecuteAsync` wartet nur auf Abbruch) – konsistent mit dem etablierten Muster, obwohl kein + eigener Takt gebraucht wird. +- Konfiguration über `DeploymentcenterOptions.Token`/`.ErrorReportingEnabled` (aus + `appsettings.Local.json`) – anders als beim Watchdog gibt es hierfür keine bestehende + UI-Heimat in `ServerSettings`, passt also zur in D-1 begründeten Aufteilung. +- 14 neue Tests (Payload, Auth-Header, Secrets aus Nachricht UND Stacktrace, Ratenbegrenzung, + alle drei Quellen inkl. `internal` gemachter Handler-Methoden für direkte Testbarkeit ohne echte + Prozessabstürze zu provozieren). Gesamt 468 Tests grün. + +**Live-Verifikation (17.08.2026, echter Server):** Zwei identische Meldungen über +`/api/errors/v1/report` mit dem exakten Payload-Format des neuen Codes → erste Meldung +`is_new: true, occurrence_count: 1`, zweite `is_new: false, occurrence_count: 2` – serverseitige +Deduplizierung bestätigt. + +**Nebenbefund beim Live-Test: D-16** (neu, siehe §1) – das `environment`-Feld im Report-Body ist +ein undokumentiertes Auth-Gate gegen die Provisionierung des Tokens; die Fehlermeldung bei einem +Mismatch nennt fälschlich einen fehlenden Scope. Für uns gelöst durch Neuprovisionierung mit +`environment: "all"` (Token in `appsettings.Local.json` aktualisiert, betrifft auch den +Watchdog-Token-Kommentar dort). + +--- + +### D-0 · Fundament (ohne Serverkontakt) — ursprüngliche Planung + +- `Directory.Build.props` mit **einer** `` für alle Projekte (D-8). +- `Deploymentcenter.BuildInfo.targets` nach `build/` übernehmen und einbinden; `BuildInfo.Version` + ersetzt das heutige `Assembly.GetName().Version` im Heartbeat (D-7). +- SDK paketieren: `dotnet pack Deploymentcenter.Client -c Release -o lib/nuget`; `nuget.config` um + das Quellen-Mapping `Deploymentcenter.*` → `local` ergänzen; `LicenseLabrador.Client` samt + `.nupkg` entfernen. +- Neuer Konfigurationsabschnitt `Deploymentcenter` in `appsettings.json` + `DeploymentcenterOptions` + (BaseUrl, ProjectSlug, Channel, Token, LicenseKey, Watchdog-Source/-Instance/-Intervall, + Schalter für Heartbeat / Fehlermeldungen / Update-Prüfung). + +**Abnahme:** Solution baut, Tests grün, `BuildInfo.Version` stimmt mit `` überein, +`lib/nuget` enthält nur noch das DC-Paket. + +### D-1 · Watchdog auf die Deploymentcenter-API umstellen — ursprüngliche Planung + +- Endpunkte `…/api/watchdog/v1/ping` und `…/api/watchdog/v1/event`, Header + `Authorization: Bearer`. TLS-Zwang und Loopback-Ausnahme bleiben. +- Payload ergänzen: `version` (aus `BuildInfo`), `os`, `group`. +- `checks` füllen — das ist der eigentliche Gewinn gegenüber heute: `db` (MySQL erreichbar), + `market_feed` (Alter des letzten Ticks), `vpn` (Mullvad-Zustand), `modules` (laufen die aktiven + Module). Ein `ok`-Heartbeat mit fehlschlagender Prüfung wird serverseitig auf `warning` gestuft. +- `metrics` schlank halten (Jobs in der Warteschlange, offene Orders als **Anzahl**). Weiterhin + **keine** Kontostände, Wallet-Adressen oder Trader-Namen. +- Beim Herunterfahren `status: "stopped"` senden (zusätzlich zum bestehenden + `stopped_graceful`-Event) — das ist der vom Evaluator ausgewertete Weg. +- Konfiguration aus `DeploymentcenterOptions`; die alten `Watchdog*`-Felder aus `ServerSettings` + entfernen (mit einmaliger Übernahme beim Start, damit niemand neu tippt). +- `WatchdogHeartbeatTests` nachziehen. + +**Abnahme:** Monitor erscheint auf `dc.mhdf.de` mit korrekter `version`; Beenden über die +Menüleiste → Monitor `stopped`, **kein** Alarm; Prozess abschießen → nach 4× Intervall genau **ein** +Down-Alarm. Voraussetzung, die vorher zu prüfen ist: `cli/tick.php` läuft dort tatsächlich als +Cron — ohne ihn sind alle Monitorzustände wertlos (`/api/health` → `checks.evaluator.ok`). + +### D-2 · Lizenz auf das Deploymentcenter umstellen — ursprüngliche Planung + +- `LicenseGate` auf `Deploymentcenter.Client.LicenseClient` umbauen: + `ValidateAsync(slug, key, baseUrl, appVersion)`, Ergebnis `LicenseValidationResult`. + `LicenseClient.DefaultAppVersion = BuildInfo.Version` einmal beim Start. +- Statusabbildung: `IsValid` → voller Start; **`IsTransient` → weiterlaufen und nur warnen** + (Netzstörung ist kein Urteil); alles andere → eingeschränkter Modus ohne Module. + `https://` selbst erzwingen (D-3). +- Eigene Prompt-Abstraktion bauen (D-4): Avalonia-Dialog für den Desktop, Konsolenvariante für + `--headless`. Der Dialog zeigt Hardware-ID (kopierbar), Status und Offline-Restlaufzeit. +- **Gate und Dialog entstehen im Avalonia-Projekt** — dort läuft heute gar keine Prüfung, und die + WinForms-Fassung wird nicht mitgezogen (Leitentscheidung 6). `Licensing/LicenseGate.cs` und + `Ui/LicenseDialog.cs` werden mit D-6 gelöscht, nicht portiert. +- CLI-Schalter `--license-status`, `--license-set-key`, `--license-deactivate` nach der Konvention + des Guides. + +**Abnahme:** gültige Lizenz → alle Module; Netz getrennt → `valid_offline` mit sichtbarer +Restlaufzeit; Schlüssel entfernt → nur Core-Shell, kein Absturz; `--headless` ohne Display +blockiert nicht. + +**Vor dem ersten echten Start im DC einstellen:** Produkt `polytrader` mit großzügigem +`max_activations` (ein Windows-Neuinstall erzeugt eine neue MachineGuid = neuer Platz) und +`default_cache_ttl_hours` als Offline-Kulanz für eigene Maschinen. + +> **Ein Aktivierungsplatz wird beim ersten echten Start verbraucht.** Die Prüfung deshalb nicht +> „mal eben" von einer Entwicklermaschine aus ausprobieren, sondern dort, wo die Installation +> hingehört — so wie beim letzten Mal gehandhabt. + +### D-3 · Fehler-Reporting — ursprüngliche Planung + +- `DeploymentcenterErrorReporter` in `PolyTrader.Core`: `POST /api/errors/v1/report`, Scope + `bugtracker:report`. +- Quellen: `AppDomain.UnhandledException` (`level: "fatal"`), + `TaskScheduler.UnobservedTaskException` (`warning`), `TerminalLogger`-Fehler (`error`). +- Pflichtmerkmale: No-Throw, keine Rückkopplung (D-13), eigene Ratenbegrenzung, + `Sanitize()` auf Meldung **und** Stacktrace, abschaltbar über die Konfiguration. +- `client_ref` je Prozessstart+Fehlergruppe setzen, damit ein Neuversuch kein Duplikat anlegt. +- Serverseitig Ignore-Regeln für bekanntes Rauschen anlegen (Polymarket-429, VPN-Reconnect) — + jeweils **mit** Alarmschwelle, denn der Zähler ist der eigentliche Zweck. + +**Abnahme:** erzwungener Testfehler erscheint als Gruppe; zweites Auftreten erhöht +`occurrence_count`, statt ein zweites Item anzulegen; bei nicht erreichbarem DC läuft PolyTrader +unverändert weiter und erzeugt **keine** Fehlerschleife. + +### D-4 · Auslieferung (UpdateService) ✅ VOLLSTÄNDIG ERLEDIGT (20.08.2026) + +**Umgesetzt (Schritte 1 und 3 der ursprünglichen Planung):** + +- Update-Prüfung beim Start in [LauncherWindow.axaml(.cs)](../../src/PolyTrader.App.Avalonia/Views/LauncherWindow.axaml): + fire-and-forget, nur aktiv bei `Deploymentcenter:UpdateCheckEnabled` (Vorgabe `false`), nutzt + `ReleaseCredentials.FromLicenseKey(...)` mit dem entschlüsselten `ServerSettings.LicenseKey`. + Ein gefundenes Update erscheint als Banner (Version, Changelog, `is_critical`-Hinweis) mit + „Jetzt installieren …" und „Später". **Installiert nie automatisch** — deckt sich mit + Leitentscheidung 4. `result.Unauthorized` wird erkannt und bewusst still behandelt (die + Lizenzprüfung aus D-2 hat eine tote Lizenz bereits gemeldet, ein zweiter Hinweis wäre Rauschen). +- Installation nach Bestätigungsdialog über `UpdateClient.LaunchUpdateAgent(..., exitCurrentApp: + false, waitForCurrentProcess: true, waitTimeoutSeconds: 40, licenseKey: ...)`, danach + `_uiHost.RequestShutdown()` — **derselbe** geordnete Weg wie „Beenden" über die Menüleiste, + inklusive der 10-Sekunden-Sicherheitsabfrage. Kein Sonderpfad, der die Bestätigung umgeht. +- `deploy/packager.config.example.json` (Schritt 1) mit den `excludePatterns`/`preservePatterns` + aus der Planung, plus Nachtrag `*.pfx/*.key/*.pem/*.p12` (der Packager selbst prüft seit + Version 2.5.0 zusätzlich Dateiname UND Inhalt, siehe D-6 der Befunde — diese Liste ist die + erste, nicht die einzige Verteidigungslinie). `deploy/packager.config.json` (echte FTP-Zugangsdaten + + `updateservice:publish`-Token) neu in `.gitignore`. +- **Live geprüft (HTTP-Vertrag, per curl):** + `GET /api/updateservice/v1/check?product=polytrader&version=0.1.0&channel=prod` → + `update_available: false, latest_version: "0.1.0"` — bestätigt, dass für `polytrader` aktuell + **kein** Release auf dem Server liegt (die `2.0.1`-Zeile in `sql/schema.sql` ist Beispiel-Seed-Daten + aus dem Deploymentcenter-Repo, nicht live). Der Code-Pfad selbst nutzt dieselbe SDK-Methode, die + bereits in D-1/D-2/D-3 erprobt ist. + +**Nachgezogen (20.08.2026, auf Richards ausdrücklichen Wunsch — „lass uns einen Win und Linux +Release erstellen und hochladen"):** + +- Signierschlüssel serverseitig bestätigt: `GET /api/updateservice/v1/pubkey` liefert einen + RSA-Key (Fingerprint `ed99a38d…`) — die zuvor offene Checkliste-Voraussetzung war in Wahrheit + schon erfüllt. +- `dotnet publish -c Release --self-contained true` für `win-x64` (124 MB) und `linux-x64` + (120 MB) in saubere `/tmp`-Verzeichnisse, **nicht** das Debug-Output. Self-contained bewusst + gewählt, weil der Zweck genau der Erstinstallations-Test auf einem frischen System ist, das + nicht zwingend eine .NET-Laufzeit mitbringt. +- **Fund dabei:** `appsettings.Local.json` (mein echtes lokales MySQL-Passwort + Dev-Token) landete + im Publish-Output — die `CopyToOutputDirectory`-Regel in der `.csproj`, die genau für diesen + Fall in D-6 der Befunde und im eigenen `packager.config.example.json`-Kommentar dokumentiert ist. + Vor jedem weiteren Schritt von Hand entfernt, dann zusätzlich mit einem eigenen Grep nach + gefüllten `Password=`-Feldern, `dc_master_…`/`dc_sub_…`-Tokens und privaten Schlüsseln über beide + Verzeichnisse geprüft (leer). Der Packager selbst hätte es beim `--allow-secrets`-losen Lauf + ohnehin blockiert — das war die zweite, nicht die einzige Verteidigungslinie. +- **`deploy/packager.config.json`** (gitignored) angelegt. FTP-Zugangsdaten kamen auf Richards + Anweisung aus `Deploymentcenter/scripts/deploy_config.json` (dieselbe Hosting-Zugangsdaten, mit + denen auch das Deploymentcenter selbst deployt wird). **Remote-Pfad per read-only FTPS-Listing + verifiziert statt geraten:** `/releases/` liegt als eigenständiges Verzeichnis auf FTP-Root-Ebene + (nicht unter `/public_html/releases/`, wie das Beispiel im UPDATESERVICE-Guide nahelegt) — + bestätigt durch die dort bereits vorhandenen Verzeichnisse `clawddotnet/` und `predictalytics/` + echter Nachbarprojekte. `ftpRemoteBaseDir: "/releases"` entsprechend gesetzt. +- Ein `updateservice:publish`-Token separat provisioniert (`environment: "all"`, wie schon bei D-3 + gelernt) — nicht das Dev-Token aus `appsettings.Local.json` wiederverwendet, das hat nur + `watchdog:ping`/`bugtracker:report`. +- **Beide Releases veröffentlicht** über `pack-and-deploy`: `polytrader 0.1.0 (prod, win-x64)` und + `polytrader 0.1.0 (prod, linux-x64)`. Versionsgegenprobe lief automatisch durch (Assembly-Version + stimmte mit `--version` überein). `appsettings.json` korrekt als „GESCHÜTZT" (preservePatterns) + markiert, `*.pdb` je Projekt korrekt ausgeschlossen. + +**Live-Abnahme (20.08.2026):** + +- [x] `GET /api/updateservice/v1/check?product=polytrader&version=0.0.0&channel=prod&platform=win-x64` + **und** `...&platform=linux-x64` → beide `update_available: true, latest_version: "0.1.0"`. +- [x] Beide Antworten enthalten ein gefülltes `manifest_signature`-Feld — signiert. +- [x] Zugangsschutz: **nicht sofort aktiv** — `curl -I .../package.tar.gz` lieferte direkt nach dem + Publish noch `200` statt `401`. Deckt sich mit der Dokumentation: für ein *neues* Produkt + entsteht `/releases/polytrader/` erst mit dem ersten Upload, der Schutz erst mit dem + nächsten `cli/tick.php`-Durchlauf. Per Hintergrund-Poll auf `401` gewartet statt einfach + anzunehmen, dass er greift — nach rund zwei Minuten aktiv geworden. +- [x] **Vollständige Selbsttest-Sequenz danach grün:** ohne Zugangsdaten → `401` (beide + Plattformen); mit dem Dev-Lizenzschlüssel als Basic-Auth (`lic_:`) + → `200`; `.htpasswd` direkt abgerufen → `403`. + +Reihenfolge ist hier bindend — siehe D-5. + +**Ursprüngliche Planung (jetzt umgesetzt, s.o.):** + +1. `packager.config.json` mit strikten `excludePatterns` (`appsettings.Local.json`, `master.key`, + `openrouter.key`, `server_settings.xml`, `*.db`, `Logs/**`, `*.pdb`) und `preservePatterns` + (`appsettings.json`). +2. Sauberes Publish-Verzeichnis je Plattform (`win-x64`, `linux-x64`), **nicht** das Debug-Output. +3. In der Anwendung: Update-Prüfung beim Start mit + `credentials: ReleaseCredentials.FromLicenseKey(...)`, Anzeige im Launcher, Installation nur auf + Bestätigung und über den geordneten Shutdown-Pfad (D-1). +4. **Erst danach** das erste Release veröffentlichen. +5. Zugangsschutz erzeugen und **Selbsttest** laufen lassen; zusätzlich von Hand: + `curl -I …/releases/polytrader/prod/win-x64//package.tar.gz` → 401. + +**Abnahme:** Update auf einem Testsystem behält `appsettings.Local.json`, `server_settings.xml`, +`master.key` und `data.db`; eine im neuen Release entfallene DLL ist verschwunden; ein Abbruch +mitten im Anwenden hinterlässt eine lauffähige Installation (Rollback); und der Blick ins Archiv: + +```bash +tar -tzf package.tar.gz | grep -Ei 'local|master\.key|openrouter|settings\.xml|\.db$' +``` + +Diese Ausgabe muss **leer** sein. + +### D-5 · Erstinstallation (`setup.json`) ✅ Code-seitig erledigt (17.08.2026), Live-Abnahme offen + +**Umgesetzt:** + +- [setup.json](../../src/PolyTrader.App.Avalonia/setup.json) im App-Projekt (per `CopyToOutputDirectory` + im Publish-Paket) mit zwei Zielen: `appsettings.json` (Deploymentcenter-Block: `BaseUrl` via + `detect:baseurl`, `ProjectSlug` via `detect:project`, `Channel`, `Token` via `provision` mit den + Scopes `watchdog:ping`+`bugtracker:report`) und `appsettings.Local.json` (`Database:MySqlConnectionString` + als `secret`). + **Korrektur gegenüber der ursprünglichen Planung:** kein `Watchdog:Source`-Feld — die + Watchdog-Konfiguration liegt seit D-1 bewusst in `server_settings.xml` (maskierte UI, + Live-Reload), und `setup.json` kann nur `json`/`env`-Ziele schreiben, kein XML. Aktivierung von + Watchdog **und** Eintragen des Lizenzschlüssels bleiben deshalb bewusste Post-Install-Schritte + über das Einstellungsfenster — beide sind seit D-1/D-2 genau dafür gebaut (maskierte + Eingabe-Buttons, sofort wirksam ohne Neustart). Im `setup.json`-Kommentar dokumentiert, damit das + nicht als vergessenes Feld missverstanden wird. +- **Master-Key-Erzeugung (D-10)** in [MasterKeyResolver.cs](../../src/PolyTrader.Core/Security/MasterKeyResolver.cs) + (neu, testbar in `PolyTrader.Core` statt direkt in `Program.cs` verdrahtet) + gemeinsamer + [FilePermissions.cs](../../src/PolyTrader.Core/Security/FilePermissions.cs)-Helfer (ersetzt die + bisher in `SettingsWindow.axaml.cs` duplizierte `RestrictToOwner`-Logik). Reihenfolge: + Umgebungsvariable → vorhandene Datei → **nur wenn beides fehlt** neu erzeugen. 8 Tests, u.a. + explizit „ein zweiter/dritter Aufruf erzeugt niemals neu" und „ein fehlgeschlagener Schreibversuch + wirft nie, sondern warnt". Live geprüft: frisches Verzeichnis ohne `master.key` → + `--smoke-ui`-Lauf erzeugt die Datei mit der erwarteten Log-Meldung, `SecretProtection` aktiviert + sich sofort danach in derselben Sitzung. +- [polytrader.service](../../deploy/polytrader.service) (systemd-Unit-Vorlage) löst **D-11**: setzt + `WorkingDirectory` ausdrücklich auf das Installationsverzeichnis, mit Begründung im Kommentar + (sonst laden `server_settings.xml`, relativ zum Arbeitsverzeichnis, und `master.key`, relativ zu + `AppContext.BaseDirectory`, aus unterschiedlichen Orten). `SIGTERM` mit 45 s Frist für den + geordneten Shutdown-Pfad statt `SIGKILL`. + +**Bewusst nicht gemacht:** + +- **Installationskonto im DC anlegen** — das Anlegen eines Kontos mit Zugangsdaten ist eine + Handlung, die grundsätzlich beim Menschen bleibt, nicht beim Agenten. Richard legt es im WebUI + unter *System → 👤 Benutzer* an (Rolle `installer`, siehe SETUP_INTEGRATION_GUIDE §2). +- **Volle Live-Abnahme** (`update-agent --action install` auf einem echten System) — braucht ein + veröffentlichtes Release (D-4, noch offen, siehe dort) und das Installationskonto von oben. Ohne + beides lässt sich nur die App-seitige Hälfte prüfen, was hiermit geschehen ist. + +Ursprünglich geplant, jetzt überholt: +- **Master-Key beim ersten Start selbst erzeugen** (Entscheidung Richard, 14.08.2026): + `ConfigureSecretProtection` legt, wenn weder `POLYTRADER_MASTER_KEY` noch `master.key` vorliegen, + einen kryptografisch zufälligen Schlüssel an und schreibt ihn mit `File.SetUnixFileMode(…, + UserRead | UserWrite)` — sonst entsteht er unter Linux mit `644`, also world-readable. + Drei Punkte, die dabei nicht untergehen dürfen: + - **Nur erzeugen, wenn wirklich keiner da ist.** Ein versehentliches Überschreiben macht jedes + bereits verschlüsselte Secret in der Datenbank unlesbar — das ist der einzige wirklich + unumkehrbare Fehler in diesem ganzen Plan. Erzeugung deshalb ausschließlich beim Nichtvorhandensein + der Datei, mit deutlichem Log-Eintrag und einem Hinweis, den Schlüssel zu sichern. + - **Ort ist `AppContext.BaseDirectory`** — dieselbe Stelle, an der er heute gelesen wird. Zusammen + mit D-11 klären, dass die Anwendung nach der Installation dieselbe Datei findet. + - **`master.key` gehört in `excludePatterns`** (D-6) und darf nie in ein Paket geraten. +- Installationskonto im DC anlegen (Rolle `installer`, **kein** Admin). +- systemd-Unit schreiben — der Installer legt bewusst keinen Dienst an. Dabei D-11 auflösen + (`WorkingDirectory` = Installationsverzeichnis oder absolute Pfade). + +**Abnahme:** `update-agent --action install` auf einem frischen Linux-System führt zu einer +lauffähigen Installation, ohne dass eine Datei von Hand bearbeitet wird; `master.key` entsteht mit +`600`; ein zweiter Start erzeugt **keinen** neuen Schlüssel. + +### D-6 · Watchdog und LicenseLabrador ausbauen + +Entscheidung Richard (14.08.2026): kein Parallelbetrieb. Läuft **nach** D-1 und D-2, damit zu +keinem Zeitpunkt beides fehlt. + +- `LicenseLabrador.Client` aus `PolyTrader.App.csproj` und `lib/nuget` entfernen, Quellen-Mapping + in `nuget.config` von `LicenseLabrador.*` auf `Deploymentcenter.*` umstellen. +- `Licensing/LicenseGate.cs`, `Ui/LicenseDialog.cs` (+ `.Designer.cs`) löschen — ersetzt durch die + Avalonia-Fassung aus D-2. +- Alte `Watchdog*`-Felder aus `ServerSettings` und der Settings-Oberfläche entfernen (der + Token-Button und die Statusanzeige wandern zum Deploymentcenter-Block). +- Beide Umsetzungspläne als **abgelöst** kennzeichnen, `ANALYSE-Linux-Portierung.md` §5.2 und die + Risikozeile R6 korrigieren (D-4 der Befunde: `ILicensePrompt` trägt die headless-Story nicht). +- Serverseitig: `watchdog.mhdf.de` und `license.mhdf.de` abschalten, sobald die Live-Abnahme steht. + Damit erledigen sich die beiden offenen Auflagen aus dem Altplan (Secrets rotieren, UTC/`NOW()`) + von selbst — **nicht** vorher abschalten, sonst fehlt die Rückfallebene während der Abnahme. + +**Abnahme:** Die Solution baut ohne LicenseLabrador-Paket, `grep -ri licenselabrador` findet im +Code nur noch historische Erwähnungen in Plandokumenten, und `server_settings.xml` enthält keine +Watchdog-Felder mehr. + +> **Reihenfolge:** Die alten Dienste erst abschalten, wenn D-1 und D-2 **live** abgenommen sind — +> nicht, wenn sie gebaut sind. + +--- + +## 4. Reihenfolge, Abhängigkeiten, Aufwand + +| Schritt | Inhalt | hängt an | Aufwand | Stand | +|---|---|---|---|---| +| D-0 | Fundament: Version, SDK-Paket, Optionen | — | 0,5 PT | ✅ 14.08.2026 | +| D-1 | Watchdog umstellen | D-0 | 0,5–1 PT | ✅ 14.08.2026, live verifiziert | +| D-2 | Lizenz umstellen + Prompt-Naht (nur Avalonia) | D-0 | 1 PT | ✅ 15.08.2026, live verifiziert | +| D-3 | Fehler-Reporting | D-0 | 0,5–1 PT | ✅ 17.08.2026, live verifiziert | +| D-4 | Auslieferung | D-0, D-2 (Schlüssel für den Paketabruf) | 1–2 PT | ✅ 20.08.2026, `polytrader 0.1.0` live für win-x64+linux-x64, signiert, live geprüft | +| D-5 | Erstinstallation + Master-Key-Erzeugung | D-4 | 0,5–1 PT | Code ✅ 17.08.2026; Release liegt jetzt vor — Live-Install fehlt nur noch das Installationskonto (Richard) | +| D-6 | Watchdog/LicenseLabrador ausbauen | Live-Abnahme von D-1 und D-2 | 0,5 PT | offen — beide Vorbedingungen live ✅, wartet auf Richards manuelle GUI-Abnahme | + +**Summe 4–7 PT.** D-1, D-2 und D-3 sind untereinander unabhängig und können in beliebiger +Reihenfolge laufen. Zwei Reihenfolgen sind dagegen bindend: + +- **D-2 vor D-4** — ohne Lizenzschlüssel im Update-Weg sperrt uns der Zugangsschutz mit dem ersten + Release aus (Befund D-5). +- **D-6 nach der Live-Abnahme**, nicht nach dem Bau. + +### Serverseitige Voraussetzungen (vor D-1) + +- [x] Projekt `polytrader` im Deploymentcenter angelegt (Slug festgelegt, id 2, aus dem Seed-Bestand) +- [x] `cli/tick.php` läuft als Cron — `/api/health` meldet `checks.evaluator.ok: true` (14.08.2026 geprüft) +- [x] Master-Token vorhanden; Dev-Sub-Token für `polytrader` provisioniert (Scopes `watchdog:ping`, + `bugtracker:report`) — für den Produktivbetrieb ein eigenes Anwendungstoken je Installation + nachziehen, siehe Leitentscheidung 8 +- [ ] Release-Signierschlüssel hinterlegt (`/api/updateservice/v1/pubkey` antwortet) — vor D-4 +- [x] `max_activations` für `polytrader` großzügig gesetzt (Richard, vor 15.08.2026: Dev-Lizenz mit + 9999 Aktivierungen) — `default_cache_ttl_hours` ungeprüft übernommen (Live-Test zeigte + plausible ~72h Offline-Kulanz), bei Bedarf im Admin nachjustieren +- [ ] Installationskonto (Rolle `installer`) angelegt — vor D-5 + +--- + +## 5. Entscheidungen (Richard, 14.08.2026) + +| Frage | Entscheidung | Wirkt auf | +|---|---|---| +| Produkt-Slug | **`polytrader`**, `pt` entfällt | D-2, D-4, Serverseite | +| Zielarchitektur | **Alles auf das Deploymentcenter.** Watchdog und LicenseLabrador verschwinden, kein Parallelbetrieb | D-6 | +| Oberfläche | **Nur noch Avalonia**, WinForms wird demnächst vollständig ausgebaut | Leitentscheidung 6, D-2, D-4 | +| Master-Key | **Bei der Erstinstallation erzeugen** | D-5, D-10 | + +## 6. Weiterhin offen + +**Stand 17.08.2026:** Von den ursprünglich zwei DC-seitigen Wünschen ist einer erledigt +(Zugangsdaten aus argv → `DC_LICENSE_KEY`-Umgebungsvariable, `waitTimeoutSeconds` einstellbar, +`BuildInfo.targets` jetzt im Paket — alles Commit `1967b49`, siehe §0). **Offen bleibt nur D-3 der +Befunde** (unsigniertes Lizenzurteil) — vom Deploymentcenter-Team selbst bestätigt und bewusst +zurückgestellt. Trägt für den Eigenbetrieb, wird relevant, sobald Dritte die Software bekommen. +Kein Blocker für diesen Plan. + +Dazu neu **D-16** (§1): das `environment`-Feld in `/api/errors/v1/report` ist ein undokumentiertes +Auth-Gate mit irreführender Fehlermeldung — für uns durch Neuprovisionierung gelöst (Token mit +`environment: "all"`), als Wunsch an DC offen (entweder die echte Ursache in der Fehlerantwort +nennen, oder die Kopplung entfernen). + +### Entscheidung nötig: erstes Release veröffentlichen (D-4) + +D-4 ist bis auf die eigentliche Auslieferung fertig — Update-Prüfung, Installations-Bestätigung und +der geordnete Neustart-Weg sind gebaut und laufen über denselben Pfad wie „Beenden". **Nicht +durchgeführt:** ein Paket schnüren und veröffentlichen. Drei Dinge sprechen dafür, das nicht +nebenbei zu tun, sondern mit Richard abzustimmen: + +1. Ein veröffentlichtes Release ist auf einem **von mehreren Projekten geteilten** Server + persistent, nicht so folgenlos rückgängig zu machen wie ein lokaler Build. +2. Der Zugangsschutz (§5A des UpdateService-Guides) verändert `.htaccess`/`.htpasswd` in + `/releases/` — einem Verzeichnisbaum, den auch andere Produkte nutzen. +3. Der **Release-Signierschlüssel ist serverseitig noch nicht bestätigt** (offener Punkt in der + Checkliste §4). Ein erstes, unsigniertes Release wäre vermeidbar. + +**Sobald Richard grünes Licht gibt:** `deploy/packager.config.json` aus der Vorlage anlegen (echtes +`updateservice:publish`-Token, FTP-Zugangsdaten), `dotnet publish` für `win-x64` und `linux-x64` in +saubere Verzeichnisse (nicht das Debug-Output), `pack-and-deploy` je Plattform aufrufen, danach +Zugangsschutz + Selbsttest prüfen (§4B der Planung). diff --git a/lib/nuget/Deploymentcenter.Client.2.5.0.nupkg b/lib/nuget/Deploymentcenter.Client.2.5.0.nupkg new file mode 100644 index 0000000..e3287e3 Binary files /dev/null and b/lib/nuget/Deploymentcenter.Client.2.5.0.nupkg differ diff --git a/src/PolyTrader.App.Avalonia/App.axaml b/src/PolyTrader.App.Avalonia/App.axaml index 86d9486..63087f7 100644 --- a/src/PolyTrader.App.Avalonia/App.axaml +++ b/src/PolyTrader.App.Avalonia/App.axaml @@ -3,71 +3,112 @@ x:Class="PolyTrader.App.Avalonia.App"> + + + Barlow, Inter, Segoe UI, sans-serif + Barlow Condensed, Barlow, Inter, Segoe UI, sans-serif + Consolas, Menlo, DejaVu Sans Mono, monospace + - - - - - + + + + + + + - - - + + + + + + + + + + - - - - - - - + + + + + + + + + + - - - + + + - - + + - - - - - + + + + + + + - - - + + + + + + + + + - - - + + + + + - - - + + + - - + + - - + + @@ -77,62 +118,275 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PolyTrader.App.Avalonia/App.axaml.cs b/src/PolyTrader.App.Avalonia/App.axaml.cs index 750ed81..ea5581f 100644 --- a/src/PolyTrader.App.Avalonia/App.axaml.cs +++ b/src/PolyTrader.App.Avalonia/App.axaml.cs @@ -42,12 +42,16 @@ namespace PolyTrader.App.Avalonia ModuleViews.Register(uiHost, Services); ViewIcons.AssignDefaults(uiHost); - var launcher = new LauncherWindow(uiHost, Services); - uiHost.SetMainWindow(launcher); - desktop.MainWindow = launcher; + // Einfenster-Shell: ein Fenster, die Views hängen als Seiten darin. + var shell = new ShellWindow(uiHost, Services); + uiHost.SetShell(shell); + desktop.MainWindow = shell; - // Schliessen des Launchers laeuft ueber die Sicherheitsabfrage (wie bisher das X). + // Schliessen der Shell laeuft ueber die Sicherheitsabfrage (wie bisher das X). desktop.ShutdownMode = global::Avalonia.Controls.ShutdownMode.OnMainWindowClose; + + // Erste Seite: die Startseite (frühere LauncherForm). + uiHost.ActivateMain(); } base.OnFrameworkInitializationCompleted(); diff --git a/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml b/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml deleted file mode 100644 index 65c3148..0000000 --- a/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml.cs b/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml.cs deleted file mode 100644 index 71c981c..0000000 --- a/src/PolyTrader.App.Avalonia/Controls/WindowMenuBar.axaml.cs +++ /dev/null @@ -1,30 +0,0 @@ -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/Licensing/LicenseCli.cs b/src/PolyTrader.App.Avalonia/Licensing/LicenseCli.cs new file mode 100644 index 0000000..8b53c5e --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Licensing/LicenseCli.cs @@ -0,0 +1,143 @@ +using System; +using System.Linq; +using Deploymentcenter.Client; +using PolyTrader.Core; +using PolyTrader.Core.Security; +using PolyTraderSharp.Models; + +namespace PolyTrader.App.Avalonia.Licensing +{ + /// + /// Kopflose Lizenzverwaltung von der Kommandozeile aus (D-2), nach der Konvention aus + /// LICENSE_INTEGRATION_GUIDE §4: --license-status, --license-set-key, + /// --license-deactivate. Diese Schalter baut die Anwendung selbst, das SDK bringt nur die + /// Bausteine mit. Läuft VOR dem Host-Aufbau und beendet den Prozess über den Rückgabewert. + /// + internal static class LicenseCli + { + /// Liefert true, wenn einer der Lizenz-Schalter erkannt und abgearbeitet wurde. + public static bool TryHandle(string[] args, out int exitCode) + { + exitCode = 0; + + if (args.Any(a => string.Equals(a, "--license-status", StringComparison.OrdinalIgnoreCase))) + { + exitCode = Status(); + return true; + } + + int setIdx = Array.FindIndex(args, a => string.Equals(a, "--license-set-key", StringComparison.OrdinalIgnoreCase)); + if (setIdx >= 0) + { + string? key = setIdx + 1 < args.Length ? args[setIdx + 1] : null; + exitCode = SetKey(key); + return true; + } + + if (args.Any(a => string.Equals(a, "--license-deactivate", StringComparison.OrdinalIgnoreCase))) + { + exitCode = Deactivate(); + return true; + } + + return false; + } + + private static int Status() + { + var options = Program.LoadDeploymentcenterOptions(); + var hw = HardwareId.GetHardwareId(options.ProjectSlug); + var cached = LicenseClient.TryGetCachedState(options.ProjectSlug); + + Console.WriteLine($"HWID v2: {hw.HardwareId} ({hw.HwidSource})"); + Console.WriteLine($"Produkt: {options.ProjectSlug}"); + Console.WriteLine($"Server: {(string.IsNullOrWhiteSpace(options.BaseUrl) ? "(keine URL konfiguriert)" : options.BaseUrl)}"); + Console.WriteLine($"Cache: {(cached == null ? "leer" : cached.Status)}"); + + if (cached != null && cached.CacheExpiresAt > 0) + { + var until = DateTimeOffset.FromUnixTimeSeconds(cached.CacheExpiresAt); + Console.WriteLine($"Offline noch bis: {until:u}"); + } + + var settings = ServerSettings.Load("server_settings.xml"); + Console.WriteLine($"Gespeicherter Schlüssel (server_settings.xml): {(string.IsNullOrWhiteSpace(settings.LicenseKey) ? "keiner" : settings.LicenseKeyStatus)}"); + return 0; + } + + private static int SetKey(string? key) + { + if (string.IsNullOrWhiteSpace(key)) + { + Console.Error.WriteLine("Fehler: --license-set-key erwartet den Schlüssel als nächstes Argument."); + return 1; + } + + var options = Program.LoadDeploymentcenterOptions(); + if (string.IsNullOrWhiteSpace(options.BaseUrl)) + { + Console.Error.WriteLine("Fehler: Keine Deploymentcenter-URL konfiguriert (appsettings.json: Deploymentcenter:BaseUrl)."); + return 1; + } + + LicenseClient.DefaultAppVersion = BuildInfo.Version; + var client = new LicenseClient(); + LicenseValidationResult result = client + .ValidateAsync(options.ProjectSlug, key.Trim(), options.BaseUrl, BuildInfo.Version) + .GetAwaiter().GetResult(); + + Console.WriteLine($"{result.Status}: {result.Message}"); + if (!result.IsValid) + { + return 1; + } + + const string settingsPath = "server_settings.xml"; + var settings = ServerSettings.Load(settingsPath); + settings.LicenseKey = SecretProtection.Protect(key.Trim()); + settings.Save(settingsPath); + Console.WriteLine($"Lizenz gültig und in {settingsPath} gespeichert " + + $"({(SecretProtection.IsEncrypted(settings.LicenseKey) ? "verschlüsselt" : "Klartext")})."); + return 0; + } + + private static int Deactivate() + { + var options = Program.LoadDeploymentcenterOptions(); + var settings = ServerSettings.Load("server_settings.xml"); + + string key; + try { key = SecretProtection.Unprotect(settings.LicenseKey); } + catch (Exception ex) + { + Console.Error.WriteLine($"Fehler: gespeicherter Schlüssel nicht lesbar: {ex.Message}"); + return 1; + } + + if (string.IsNullOrWhiteSpace(key)) + { + Console.Error.WriteLine("Fehler: kein Lizenzschlüssel hinterlegt."); + return 1; + } + + // Die Deaktivierung gibt einen Aktivierungsplatz frei und ist deshalb geschützt - der + // shared_key gehört auf den Administrationsrechner, nicht in die ausgelieferte + // Anwendung (siehe LICENSE_INTEGRATION_GUIDE). Ohne ihn: klarer Hinweis statt Versuch. + string? sharedKey = Environment.GetEnvironmentVariable("DC_SHARED_KEY"); + if (string.IsNullOrWhiteSpace(sharedKey)) + { + Console.Error.WriteLine("Fehler: DC_SHARED_KEY nicht gesetzt. Die Deaktivierung braucht den " + + "administrativen Shared Key - alternativ im Deploymentcenter-WebUI unter " + + "der Hardware-Liste auf „Freigeben\" klicken."); + return 1; + } + + var client = new LicenseClient(); + bool ok = client.DeactivateAsync(options.ProjectSlug, key, options.BaseUrl, sharedKey) + .GetAwaiter().GetResult(); + + Console.WriteLine(ok ? "Aktivierung freigegeben." : "Deaktivierung fehlgeschlagen."); + return ok ? 0 : 1; + } + } +} diff --git a/src/PolyTrader.App.Avalonia/Licensing/LicenseGate.cs b/src/PolyTrader.App.Avalonia/Licensing/LicenseGate.cs new file mode 100644 index 0000000..1ccc6d6 --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Licensing/LicenseGate.cs @@ -0,0 +1,105 @@ +using System; +using Deploymentcenter.Client; +using PolyTrader.Core; +using PolyTrader.Core.Configuration; +using PolyTrader.Core.Security; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; + +namespace PolyTrader.App.Avalonia.Licensing +{ + /// + /// Lizenz-Torwächter für den Programmstart (D-2). Ersetzt den früheren, gegen + /// LicenseLabrador.Client gebauten Licensing/LicenseGate.cs im WinForms-Projekt + /// (siehe docs/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md) – dort läuft + /// ohnehin keine Lizenzprüfung, weil Avalonia die tatsächlich benutzte Anwendung ist. + /// + /// **Grundsatz unverändert:** kein bei ungültiger Lizenz. Ein + /// Trading-Bot mit offenen Positionen darf nicht mitten im Lauf abgeschossen werden. Bei + /// fehlender/ungültiger Lizenz startet die Core-Shell (Terminal, Einstellungen) ohne die + /// Trading-/Analyse-Module; der Schlüssel lässt sich jederzeit über den Button + /// „Lizenzschlüssel setzen …" im Einstellungsfenster nachtragen (wirkt ab dem nächsten Start – + /// eine Laufzeit-Revalidierung gibt es bewusst nicht, siehe Entscheidung vom 31.07.2026). + /// + /// **Bewusst kein eigenes :** Der interaktive Weg läuft über die + /// bereits vorhandene, erprobte Settings-UI (derselbe maskierte Prompt-Button wie beim + /// Watchdog-Token); ein zusätzlicher Dialog nur für die Lizenz wäre eine zweite, redundante + /// Eingabe-Oberfläche. Für den Kopflos-Betrieb (--headless, CLI-Schalter) genügt die vom + /// SDK mitgelieferte – eine eigene Fassung böte keinen Mehrwert. + /// + public static class LicenseGate + { + /// + /// Prüft still (ohne Dialog), ob die gespeicherte Lizenz aktuell nutzbar ist. Entscheidet + /// darüber, ob die Trading-/Analyse-Module überhaupt registriert werden – muss deshalb vor + /// dem Host-Aufbau laufen. Wirft nie: jeder Fehler in der Prüfung führt zum eingeschränkten + /// Modus, nicht zum Absturz. + /// + public static bool CheckUsable(DeploymentcenterOptions options, ServerSettings settings, TerminalLogger logger) + { + try + { + if (string.IsNullOrWhiteSpace(options.BaseUrl)) + { + logger.Warning("⚠️ Keine Deploymentcenter-URL konfiguriert. Start im eingeschränkten Modus (nur Core-Shell)."); + return false; + } + + // Befund D-3 aus dem Umsetzungsplan: das SDK erzwingt selbst kein TLS. Ohne diese + // Prüfung ginge der Lizenzschlüssel im Klartext übers Netz, wenn die URL versehentlich + // auf http:// steht. + if (!Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out var parsed) || + !(parsed.Scheme == Uri.UriSchemeHttps || (parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback))) + { + logger.Warning($"⚠️ Deploymentcenter-URL muss HTTPS verwenden (http:// nur für localhost): '{options.BaseUrl}'. " + + "Start im eingeschränkten Modus."); + return false; + } + + string key; + try + { + // Erlaubt einen mit dem Master-Key verschlüsselten Schlüssel (enc:v1:…); + // Klartext wird unverändert durchgereicht. + key = SecretProtection.Unprotect(settings.LicenseKey); + } + catch (Exception ex) + { + logger.Warning($"⚠️ Gespeicherter Lizenzschlüssel nicht lesbar ({ex.Message}) – behandle als 'keine Lizenz'."); + return false; + } + + if (string.IsNullOrWhiteSpace(key)) + { + logger.Warning("⚠️ Kein Lizenzschlüssel gesetzt. Start im eingeschränkten Modus (nur Core-Shell). " + + "Schlüssel über „Lizenzschlüssel setzen …\" im Einstellungsfenster eintragen."); + return false; + } + + LicenseClient.DefaultAppVersion = BuildInfo.Version; + var client = new LicenseClient(); + LicenseValidationResult result = client + .ValidateAsync(options.ProjectSlug, key, options.BaseUrl, BuildInfo.Version) + .GetAwaiter().GetResult(); + + if (result.IsValid) + { + logger.Info(result.IsCached + ? $"🔑 Lizenz im Offline-Kulanzmodus gültig ({result.Status})." + : "🔑 Lizenz gültig (online geprüft)."); + return true; + } + + logger.Warning($"⚠️ Keine gültige Lizenz ({result.Status}: {result.Message}). " + + "Start im eingeschränkten Modus (nur Core-Shell, keine Module). " + + "Schlüssel über „Lizenzschlüssel setzen …\" im Einstellungsfenster prüfen."); + return false; + } + catch (Exception ex) + { + logger.Error($"❌ Lizenzprüfung unerwartet fehlgeschlagen: {ex.Message}. Start im eingeschränkten Modus."); + return false; + } + } + } +} diff --git a/src/PolyTrader.App.Avalonia/PolyTrader.App.Avalonia.csproj b/src/PolyTrader.App.Avalonia/PolyTrader.App.Avalonia.csproj index 4dd3915..eac8dec 100644 --- a/src/PolyTrader.App.Avalonia/PolyTrader.App.Avalonia.csproj +++ b/src/PolyTrader.App.Avalonia/PolyTrader.App.Avalonia.csproj @@ -50,6 +50,9 @@ + + diff --git a/src/PolyTrader.App.Avalonia/Program.cs b/src/PolyTrader.App.Avalonia/Program.cs index 1dfa84b..d55bfa0 100644 --- a/src/PolyTrader.App.Avalonia/Program.cs +++ b/src/PolyTrader.App.Avalonia/Program.cs @@ -41,7 +41,18 @@ namespace PolyTrader.App.Avalonia 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); + // 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 @@ -82,11 +93,35 @@ namespace PolyTrader.App.Avalonia .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. /// - private static IHost BuildHost(out TerminalLogger bootLog) + /// + /// 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(); @@ -115,6 +150,16 @@ namespace PolyTrader.App.Avalonia 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() @@ -123,6 +168,7 @@ namespace PolyTrader.App.Avalonia 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 @@ -147,7 +193,22 @@ namespace PolyTrader.App.Avalonia services.AddSingleton( sp => new LogNotificationSink(sp.GetRequiredService())); - services.AddSingleton(sp => new WatchdogHeartbeatService(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/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). @@ -159,9 +220,15 @@ namespace PolyTrader.App.Avalonia 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(); @@ -189,7 +256,7 @@ namespace PolyTrader.App.Avalonia ViewIcons.AssignDefaults(uiHost); int failures = 0; - Console.WriteLine("=== Smoke-UI: Fenster-Konstruktion (Avalonia) ==="); + Console.WriteLine("=== Smoke-UI: Seiten-Konstruktion (Avalonia) ==="); foreach (var view in uiHost.Views) { try @@ -204,15 +271,17 @@ namespace PolyTrader.App.Avalonia } } + // 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.LauncherWindow(uiHost, host.Services); - Console.WriteLine("[OK] LauncherWindow konstruiert"); + _ = new Views.ShellWindow(uiHost, host.Services); + Console.WriteLine("[OK] ShellWindow konstruiert"); } catch (Exception ex) { failures++; - Console.WriteLine($"[FEHLER] LauncherWindow: {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($"[FEHLER] ShellWindow: {ex.GetType().Name}: {ex.Message}"); } try @@ -255,9 +324,12 @@ namespace PolyTrader.App.Avalonia // Schluessel faellt sonst nicht auf - ThemeManager.Brush liefert dann still Grau. string[] themeKeys = { - "AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush", "AppBorderBrush", - "AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush", + "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" @@ -298,17 +370,23 @@ namespace PolyTrader.App.Avalonia }; /// - /// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte master.key) und aktiviert - /// die at-rest-Verschlüsselung. Muss VOR jeder Credential-Entschlüsselung laufen. + /// 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? 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(); - } + 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) diff --git a/src/PolyTrader.App.Avalonia/Shell/AvaloniaUiHost.cs b/src/PolyTrader.App.Avalonia/Shell/AvaloniaUiHost.cs index 56dd856..238d97b 100644 --- a/src/PolyTrader.App.Avalonia/Shell/AvaloniaUiHost.cs +++ b/src/PolyTrader.App.Avalonia/Shell/AvaloniaUiHost.cs @@ -7,19 +7,29 @@ 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. + /// Avalonia-Umsetzung von . + /// + /// Umstellung auf die Einfenster-Shell: Früher öffnete jede View ein eigenes + /// Fenster. Jetzt gibt es genau ein Anwendungsfenster (); eine + /// View ist eine -Seite, die in dessen Inhaltsbereich gehängt wird. + /// Eine Seite wird beim ersten Aufruf erzeugt und bleibt danach für die Sitzung bestehen – + /// das entspricht dem alten „Fenster bleibt offen": Timer, Log-Abos und der Bildlaufstand + /// überleben den Seitenwechsel. /// /// 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. + /// ) – hier wird auf gecastet. Ein falscher Typ ist ein + /// Programmierfehler und soll laut scheitern, nicht still eine leere Seite erzeugen. /// public sealed class AvaloniaUiHost : IModuleUiHost { + /// ID der Startseite (frühere LauncherForm/LauncherWindow). + public const string StartViewId = "core.start"; + private readonly List _views = new(); - private readonly Dictionary _open = new(); - private Window? _mainWindow; + private readonly List _unavailable = new(); + private readonly Dictionary _pages = new(); + private Views.ShellWindow? _shell; + private string? _activeViewId; private bool _shutdownDialogOpen; /// True, sobald das Herunterfahren über die Sicherheitsabfrage bestätigt wurde. @@ -29,20 +39,43 @@ namespace PolyTrader.App.Avalonia.Shell public IReadOnlyList Views => _views; + /// ID der gerade angezeigten Seite – die Seitenleiste hebt sie hervor. + public string? ActiveViewId => _activeViewId; + public void RegisterView(ModuleView view) => _views.Add(view); - /// Setzt das Hauptfenster (Launcher) – Ziel für . - public void SetMainWindow(Window main) => _mainWindow = main; + /// + /// Ein bekanntes, in dieser Sitzung aber nicht geladenes Modul. Solche Module haben keine + /// Seite – die Seitenleiste zeigt sie trotzdem, ausgegraut und mit dem Grund als Hinweis. + /// + /// Warum das Modul fehlt (Lizenz, Abschaltung, fehlender API-Key …). + public sealed record UnavailableView(string Title, string? IconKey, int Order, string Reason); - public bool IsOpen(string viewId) => _open.ContainsKey(viewId); + /// + /// Meldet ein nicht geladenes Modul an. Ohne das verschwände es spurlos aus der Oberfläche – + /// in der Mehrfenster-Fassung fiel das kaum auf, in einer Seitenleiste ist eine unerklärt + /// leere Rubrik dagegen schlicht ein Fehler. + /// + public void RegisterUnavailable(UnavailableView view) => _unavailable.Add(view); - public void ActivateMain() - { - if (_mainWindow == null) return; - if (_mainWindow.WindowState == WindowState.Minimized) - _mainWindow.WindowState = WindowState.Maximized; - _mainWindow.Activate(); - } + /// Bekannte, aber nicht geladene Module (siehe ). + public IReadOnlyList UnavailableViews => _unavailable; + + /// Setzt das Shell-Fenster – Ziel aller Seitenwechsel und Besitzer der Dialoge. + public void SetShell(Views.ShellWindow shell) => _shell = shell; + + /// Das Shell-Fenster, oder null vor dessen Aufbau (z.B. im Smoke-Lauf). + public Window? ShellWindow => _shell; + + /// + /// Wurde die Seite in dieser Sitzung schon einmal aufgerufen? Erbt die Bedeutung des früheren + /// „Fenster ist offen": die Seite existiert und arbeitet weiter, auch wenn gerade eine andere + /// angezeigt wird. + /// + public bool IsOpen(string viewId) => _pages.ContainsKey(viewId); + + /// Zurück auf die Startseite. + public void ActivateMain() => OpenView(StartViewId); public void OpenView(string viewId) { @@ -52,35 +85,20 @@ namespace PolyTrader.App.Avalonia.Shell public void OpenView(ModuleView view) { - if (_open.TryGetValue(view.Id, out var existing)) + if (!_pages.TryGetValue(view.Id, out var page)) { - if (existing.WindowState == WindowState.Minimized) - existing.WindowState = WindowState.Normal; - existing.Activate(); - return; + page = (Control)view.CreateView(); + _pages[view.Id] = page; } - 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(); + _activeViewId = view.Id; + _shell?.ShowPage(view.Title, page); 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. + /// herunter. Aus jeder Seite aufrufbar – auch aus Modul-Seiten, die die App nicht kennen. /// public async void RequestShutdown() { @@ -89,8 +107,8 @@ namespace PolyTrader.App.Avalonia.Shell try { var dlg = new Views.ShutdownConfirmWindow(); - bool confirmed = _mainWindow != null - ? await dlg.ShowDialog(_mainWindow) + bool confirmed = _shell != null + ? await dlg.ShowDialog(_shell) : await dlg.ShowDialog(new Window()); if (!confirmed) return; diff --git a/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs b/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs index 76d9488..69308db 100644 --- a/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs +++ b/src/PolyTrader.App.Avalonia/Shell/CoreViews.cs @@ -10,7 +10,7 @@ using PolyTraderSharp.Services; namespace PolyTrader.App.Avalonia.Shell { /// - /// Registriert die Fenster, die der Core selbst beisteuert (Dashboard, Einstellungen, + /// Registriert die Seiten, die der Core selbst beisteuert (Start, 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. /// @@ -21,10 +21,25 @@ namespace PolyTrader.App.Avalonia.Shell public static void Register(IModuleUiHost host, IServiceProvider services) { + // Die Startseite ist seit der Einfenster-Shell eine View wie jede andere: sie steht in + // der Seitenleiste, wird über OpenView aufgerufen und beim Start als erste angezeigt. + // Sie heißt „Dashboard", weil sie genau das ist: Kennzahlen, Konten und Live-Überblick. + host.RegisterView(new ModuleView + { + Id = AvaloniaUiHost.StartViewId, + Title = "Dashboard", + Group = "Core", + Order = 0, + CreateView = () => new Views.LauncherWindow(host, services) + }); + host.RegisterView(new ModuleView { Id = "core.dashboard", - Title = "Dashboard", + // Umbenannt: „Dashboard" heißt jetzt die Startseite; diese Auswertungsansicht + // (KPIs, Diagramme, Tradehistorie) läuft unter „Analytics". Die View-ID bleibt + // bewusst unverändert – daran hängen Symbole, Einstellungen und die Gruppierung. + Title = "Analytics", Group = "Core", Order = 10, CreateView = () => new Views.DashboardWindow( @@ -47,7 +62,8 @@ namespace PolyTrader.App.Avalonia.Shell services.GetRequiredService(), services.GetRequiredService(), services.GetService(), - services.GetService()) + services.GetService(), + services.GetService()) }); host.RegisterView(new ModuleView diff --git a/src/PolyTrader.App.Avalonia/Shell/ModuleViews.cs b/src/PolyTrader.App.Avalonia/Shell/ModuleViews.cs index b318e76..d16fb00 100644 --- a/src/PolyTrader.App.Avalonia/Shell/ModuleViews.cs +++ b/src/PolyTrader.App.Avalonia/Shell/ModuleViews.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using PolyTrader.Core.Modularity; @@ -10,15 +11,17 @@ namespace PolyTrader.App.Avalonia.Shell /// Begründung in Views/Modules/README.md: die Modulprojekte bleiben dadurch frei von /// Avalonia, was für den kopflosen Linux-Betrieb (--headless) den Ausschlag gibt. /// - /// Registriert wird nur, was auch geladen ist: ist ein Modul über - /// DisabledModules abgeschaltet, fehlen seine Dienste im Container – dann entfällt auch - /// sein Fenster, und der Launcher zeigt es gar nicht erst an. + /// Eine Seite bekommt nur, was auch geladen ist: ist ein Modul über + /// DisabledModules abgeschaltet oder fehlt die Lizenz, fehlen seine Dienste im Container. + /// Ein solches Modul wird dann als gemeldet und + /// erscheint in der Seitenleiste ausgegraut mit dem Grund – lautlos verschwinden darf es nicht. /// public static class ModuleViews { - public static void Register(IModuleUiHost host, IServiceProvider services) + public static void Register(AvaloniaUiHost host, IServiceProvider services) { - RegisterIfLoaded(services, "CopyTrading", () => host.RegisterView(new ModuleView + RegisterIfLoaded(host, services, "CopyTrading", "Copytrading", "copytrading", 100, + () => host.RegisterView(new ModuleView { Id = "copytrading.main", Title = "Copytrading", @@ -33,7 +36,8 @@ namespace PolyTrader.App.Avalonia.Shell services.GetRequiredService()) })); - RegisterIfLoaded(services, "ResolutionFarming", () => host.RegisterView(new ModuleView + RegisterIfLoaded(host, services, "ResolutionFarming", "ResolutionFarming", "resolutionfarming", 200, + () => host.RegisterView(new ModuleView { Id = "resolutionfarming.main", Title = "ResolutionFarming", @@ -48,7 +52,8 @@ namespace PolyTrader.App.Avalonia.Shell services.GetRequiredService()) })); - RegisterIfLoaded(services, "Supervisor", () => host.RegisterView(new ModuleView + RegisterIfLoaded(host, services, "Supervisor", "Supervisor", "supervisor", 300, + () => host.RegisterView(new ModuleView { Id = "supervisor.main", Title = "Supervisor", @@ -63,7 +68,8 @@ namespace PolyTrader.App.Avalonia.Shell services.GetRequiredService()) })); - RegisterIfLoaded(services, "Accounting", () => host.RegisterView(new ModuleView + RegisterIfLoaded(host, services, "Accounting", "Accounting", "accounting", 400, + () => host.RegisterView(new ModuleView { Id = "accounting.main", Title = "Accounting", @@ -80,12 +86,58 @@ namespace PolyTrader.App.Avalonia.Shell })); } - /// Registriert die Ansicht nur, wenn das Modul in dieser Sitzung geladen ist. - private static void RegisterIfLoaded(IServiceProvider services, string moduleName, Action register) + /// + /// Registriert die Seite, wenn das Modul in dieser Sitzung geladen ist – sonst einen + /// ausgegrauten Platzhalter samt Grund. + /// + private static void RegisterIfLoaded(AvaloniaUiHost host, IServiceProvider services, + string moduleName, string title, string iconKey, int order, Action register) { bool loaded = services.GetServices() .Any(m => string.Equals(m.Name, moduleName, StringComparison.OrdinalIgnoreCase)); - if (loaded) register(); + if (loaded) + { + register(); + return; + } + + host.RegisterUnavailable(new AvaloniaUiHost.UnavailableView( + title, iconKey, order, BlockerOf(moduleName, services))); + } + + /// + /// Warum fehlt das Modul? Ein modul-eigener Grund (z.B. fehlender API-Key) ist die genaueste + /// Auskunft; sonst bleibt der allgemeine Fall: Lizenz oder DisabledModules. + /// + private static string BlockerOf(string moduleName, IServiceProvider services) + { + var config = services.GetService(); + if (config != null) + { + IPolyTraderModule? known = moduleName.ToLowerInvariant() switch + { + "copytrading" => new Modules.CopyTrading.CopyTradingModule(), + "resolutionfarming" => new Modules.ResolutionFarming.ResolutionFarmingModule(), + "supervisor" => new Modules.Supervisor.SupervisorModule(), + "accounting" => new Modules.Accounting.AccountingModule(), + _ => null + }; + + try + { + string? blocker = known?.GetActivationBlocker(config); + if (!string.IsNullOrWhiteSpace(blocker)) + return $"Modul nicht geladen: {blocker}"; + } + catch + { + // Die Auskunft ist ein Komfort, kein Muss – ein Fehler darin darf den Start + // nicht aufhalten. Dann greift der allgemeine Text. + } + } + + return "Modul in dieser Sitzung nicht geladen – Lizenz pruefen oder unter " + + "Einstellungen aktivieren (wirkt nach einem Neustart)."; } } } diff --git a/src/PolyTrader.App.Avalonia/Shell/ShellNavModel.cs b/src/PolyTrader.App.Avalonia/Shell/ShellNavModel.cs new file mode 100644 index 0000000..e87e58b --- /dev/null +++ b/src/PolyTrader.App.Avalonia/Shell/ShellNavModel.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +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 { } } + } + + /// + /// Eine große Kachel auf der Startseite. Früher WindowMenuEntry – seit der Umstellung auf + /// die Einfenster-Shell öffnet sie keine Fenster mehr, sondern schaltet die Seite um. + /// + public sealed class ViewTile + { + public string Title { get; init; } = ""; + public string? ToolTip { get; init; } + public Bitmap? Icon { get; init; } + + /// Aktuell angezeigte Seite – wird fett dargestellt. + public bool IsCurrent { get; init; } + + /// Seite wurde in dieser Sitzung schon aufgerufen – 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 ? "✓ " : ""; + } + + /// Ein Eintrag der Seitenleiste. + public sealed class ShellNavEntry + { + public string Title { get; init; } = ""; + public string? ToolTip { get; init; } + public Bitmap? Icon { get; init; } + + /// Diese Seite wird gerade angezeigt – Hinterlegung im Akzent. + public bool IsActive { get; init; } + + /// + /// false für bekannte, aber nicht geladene Module: der Eintrag steht ausgegraut da + /// und nennt im Hinweistext den Grund, statt einfach zu fehlen. + /// + public bool IsAvailable { get; init; } = true; + + /// + /// Beschriftung anzeigen? Steht bewusst am Eintrag und nicht nur am Modell: so kommt die + /// Vorlage mit einer geprüften Bindung auf ihren eigenen Datentyp aus und braucht keinen + /// Umweg über den DataContext des Fensters (der wäre nur ungeprüft bindbar). + /// + public bool LabelsVisible { get; init; } = true; + + /// Eingeklappte Leiste: nur das Symbol, mittig gesetzt (Klassenbindung im XAML). + public bool IconOnly => !LabelsVisible; + + public ICommand Command { get; init; } = new RelayCommand(() => { }); + } + + /// Ein Abschnitt der Seitenleiste („ÜBERSICHT", „MODULE", „SYSTEM"). + public sealed class ShellNavGroup + { + public string Title { get; init; } = ""; + public bool LabelsVisible { get; init; } = true; + public IReadOnlyList Entries { get; init; } = Array.Empty(); + } + + /// + /// Ein Eintrag der Benachrichtigungsleiste über dem Inhalt. Gespeist aus den Warnungen und + /// Fehlern des TerminalLogger – dieselbe Quelle wie das Widget „Warnungen & Fehler" + /// auf der Startseite, nur live und wegklickbar. + /// + public sealed class ShellNotification + { + public string Source { get; init; } = ""; + public string Message { get; init; } = ""; + + /// Fehler statt Warnung – färbt die Kante rot (Klassenbindung im XAML). + public bool IsError { get; init; } + + /// + /// Setzbar (nicht init), weil der Befehl den Eintrag selbst aus der Liste nimmt und + /// ihn dafür kennen muss – er wird direkt nach dem Erzeugen zugewiesen. + /// + public ICommand Dismiss { get; set; } = new RelayCommand(() => { }); + } + + /// + /// Datenmodell der Seitenleiste der Einfenster-Shell. Ersetzt das frühere + /// WindowMenuModel, das auf JEDEM Fenster eine Menüleiste aufgespannt hat. + /// + /// Layout-Regel: Das Modell liefert nur Daten (, Breite, + /// Sichtbarkeit der Beschriftungen). Wie ein Eintrag aussieht, steht deklarativ in + /// Views/ShellWindow.axaml – hier wird nichts zur Laufzeit zusammengebaut. + /// + public sealed class ShellNavModel : INotifyPropertyChanged + { + /// Abschnitte der Seitenleiste in Anzeigereihenfolge. + private const string GroupOverview = "ÜBERSICHT"; + private const string GroupModules = "MODULE"; + private const string GroupSystem = "SYSTEM"; + + private static readonly string[] GroupOrder = { GroupOverview, GroupModules, GroupSystem }; + + private readonly AvaloniaUiHost _host; + private bool _expanded = true; + private string _pageTitle = ""; + + public ObservableCollection Groups { get; } = new(); + + public ShellNavModel(AvaloniaUiHost host) + { + _host = host; + Rebuild(); + _host.OpenStateChanged += OnOpenStateChanged; + } + + /// Registrierung lösen, wenn die Shell geht – kein Event-Leak über die Host-Lebensdauer. + public void Detach() => _host.OpenStateChanged -= OnOpenStateChanged; + + // ===== Seitenleiste ein-/ausklappen ===== + + public bool IsExpanded => _expanded; + + /// Breite der Seitenleiste. Eingeklappt bleiben nur die Symbole stehen. + public double SidebarWidth => _expanded ? 236 : 62; + + /// Beschriftungen (Marke, Abschnittstitel, Eintragstexte, Fußzeile) nur ausgeklappt. + public bool LabelsVisible => _expanded; + + public string CollapseGlyph => _expanded ? "‹" : "›"; + public string CollapseToolTip => _expanded ? "Seitenleiste einklappen" : "Seitenleiste ausklappen"; + + public void ToggleSidebar() + { + _expanded = !_expanded; + Raise(nameof(IsExpanded)); + Raise(nameof(SidebarWidth)); + Raise(nameof(LabelsVisible)); + Raise(nameof(CollapseGlyph)); + Raise(nameof(CollapseToolTip)); + + // Die Einträge tragen die Sichtbarkeit ihrer Beschriftung selbst (siehe ShellNavEntry) – + // deshalb muss die Liste beim Ein-/Ausklappen neu aufgebaut werden. + Rebuild(); + } + + // ===== Kopfzeile ===== + + /// Titel der angezeigten Seite – steht groß in der Kopfzeile. + public string PageTitle + { + get => _pageTitle; + set { _pageTitle = value; Raise(nameof(PageTitle)); } + } + + // ===== Navigation ===== + + private void OnOpenStateChanged() + { + if (Dispatcher.UIThread.CheckAccess()) Rebuild(); + else Dispatcher.UIThread.Post(Rebuild); + } + + private void Rebuild() + { + // Geladene Seiten und nicht geladene Module kommen aus zwei Quellen, sollen in der + // Leiste aber gemeinsam nach Order stehen – deshalb erst zu einer Liste zusammenführen. + var entries = _host.Views + .Select(v => ( + Group: GroupOf(v.Id), + v.Order, + v.Title, + Entry: new ShellNavEntry + { + Title = v.Title, + ToolTip = v.Title, + Icon = ViewIcons.Resolve(v.IconKey), + IsActive = v.Id == _host.ActiveViewId, + LabelsVisible = _expanded, + Command = new RelayCommand(() => _host.OpenView(v.Id)) + })) + .Concat(_host.UnavailableViews.Select(u => ( + Group: GroupModules, + u.Order, + u.Title, + Entry: new ShellNavEntry + { + Title = u.Title, + ToolTip = u.Reason, + Icon = ViewIcons.Resolve(u.IconKey), + IsAvailable = false, + LabelsVisible = _expanded + }))) + .OrderBy(x => x.Order).ThenBy(x => x.Title) + .GroupBy(x => x.Group) + .ToDictionary(g => g.Key, g => g.Select(x => x.Entry).ToList()); + + Groups.Clear(); + foreach (var groupTitle in GroupOrder) + { + if (!entries.TryGetValue(groupTitle, out var groupEntries)) continue; + + Groups.Add(new ShellNavGroup + { + Title = groupTitle, + LabelsVisible = _expanded, + Entries = groupEntries + }); + } + + Raise(nameof(Groups)); + } + + /// + /// Ordnet eine View einem Abschnitt der Seitenleiste zu. Bewusst über die View-ID und nicht + /// über : dort steht der Modulname („CopyTrading"), was in der + /// Seitenleiste zu einem Abschnitt je Modul führen würde. + /// + private static string GroupOf(string viewId) => viewId switch + { + AvaloniaUiHost.StartViewId or "core.dashboard" => GroupOverview, + "core.jobs" or "core.terminal" or "core.settings" => GroupSystem, + _ => GroupModules + }; + + private void Raise(string name) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + + public event PropertyChangedEventHandler? PropertyChanged; + } +} diff --git a/src/PolyTrader.App.Avalonia/Shell/ViewIcons.cs b/src/PolyTrader.App.Avalonia/Shell/ViewIcons.cs index b5587f7..21169f8 100644 --- a/src/PolyTrader.App.Avalonia/Shell/ViewIcons.cs +++ b/src/PolyTrader.App.Avalonia/Shell/ViewIcons.cs @@ -17,7 +17,8 @@ namespace PolyTrader.App.Avalonia.Shell /// Symbol-Schlüssel → Dateiname in Resources/ (als Avalonia-Asset eingebettet). private static readonly Dictionary FileByKey = new() { - ["dashboard"] = "dashboard.png", + ["start"] = "dashboard.png", + ["dashboard"] = "token_quantifier.png", ["settings"] = "setting_tools.png", ["terminal"] = "error_log.png", ["jobs"] = "system_time.png", @@ -30,6 +31,7 @@ namespace PolyTrader.App.Avalonia.Shell /// Standard-Symbolschlüssel je View-ID – identisch zur WinForms-Shell. private static readonly Dictionary DefaultKeyByViewId = new() { + [AvaloniaUiHost.StartViewId] = "start", ["core.dashboard"] = "dashboard", ["core.settings"] = "settings", ["core.terminal"] = "terminal", diff --git a/src/PolyTrader.App.Avalonia/Shell/WindowMenuModel.cs b/src/PolyTrader.App.Avalonia/Shell/WindowMenuModel.cs deleted file mode 100644 index 64dea76..0000000 --- a/src/PolyTrader.App.Avalonia/Shell/WindowMenuModel.cs +++ /dev/null @@ -1,133 +0,0 @@ -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 string? ToolTip { 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; - ThemeManager.ThemeChanged += OnOpenStateChanged; // Symbol des Umschalters dreht sich - } - - /// Registrierung lösen, wenn das Fenster geht – kein Event-Leak über die Host-Lebensdauer. - public void Detach() - { - _host.OpenStateChanged -= OnOpenStateChanged; - ThemeManager.ThemeChanged -= 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)) - }); - } - - // Farbschema-Umschalter: sitzt auf JEDEM Fenster, damit er ueberall erreichbar ist. - // Sonne = ins Helle wechseln, Mond = ins Dunkle. - Entries.Add(new WindowMenuEntry - { - Title = ThemeManager.IsDark ? "☀" : "🌙", - ToolTip = ThemeManager.IsDark ? "Zum hellen Farbschema wechseln" : "Zum dunklen Farbschema wechseln", - Command = new RelayCommand(ThemeManager.Toggle) - }); - - // 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/DashboardWindow.axaml b/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml index f12c815..f1d0cd5 100644 --- a/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml @@ -1,12 +1,9 @@ - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +