UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4

Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem
Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag.

Oberflaeche
- Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als
  mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml
  (eigene ControlThemes statt Fluent umzufaerben).
- Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer
  Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite
  statt eigenem Fenster, Texteditor-Fenster.
- ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter
  entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt.

Rocket.Chat
- Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der
  Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird
  nur, wenn wirklich etwas anliegt.
- send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum
  ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist.
- Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei
  Annahmen waren falsch und sind korrigiert.

Deploymentcenter
- DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json)
  erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke.
- AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst
  ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-23 12:16:05 +02:00
co-authored by Claude Opus 5
parent b5bf97ae74
commit 33d95a6c3f
88 changed files with 11665 additions and 1029 deletions
+11
View File
@@ -234,6 +234,7 @@ public sealed class AppHost : IAsyncDisposable
registry.Register(new AgentEditorTool());
registry.Register(new Tools.Memory.MemoryTool());
registry.Register(new Tools.Taskboard.TaskboardTool());
registry.Register(new Tools.RocketChat.RocketChatTool());
return registry;
}
@@ -468,8 +469,18 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.</summary>
public static string BuildSummary => ReleaseInfo.Summary;
private bool _disposed;
public async ValueTask DisposeAsync()
{
// Beim Update fahren wir vor dem Beenden selbst herunter; danach ruft die
// Oberflaeche Shutdown, und deren Behandlung raeumt ein zweites Mal auf. Ohne
// diese Sperre gingen alle Schritte doppelt los — unter anderem ein zweiter
// Abmeldevorgang beim Watchdog, der den gerade gesetzten Wartungszustand
// wieder ueberschreibt.
if (_disposed) return;
_disposed = true;
foreach (var step in _shutdown)
{
try { await step(); }
@@ -37,6 +37,7 @@
<ProjectReference Include="..\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
<ProjectReference Include="..\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
<ProjectReference Include="..\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
<ProjectReference Include="..\ClawdDotNet.Tools.RocketChat\ClawdDotNet.Tools.RocketChat.csproj" />
<ProjectReference Include="..\ClawdDotNet.Tools.TelegramClient\ClawdDotNet.Tools.TelegramClient.csproj" />
<!-- Deploymentcenter-SDK (Fremdrepo, netstandard2.0;net8.0). Liefert Hardware-ID v2,
den verschluesselten Lizenz-Zwischenspeicher und die Update-Pruefung. Watchdog,
@@ -210,9 +210,30 @@ public sealed class DeploymentcenterService : IAsyncDisposable
try
{
var client = new global::Deploymentcenter.Client.UpdateClient();
// Seit 2.4 liegt die Release-Ablage hinter HTTP-Basic-Auth; der Lizenzschlüssel
// ist der Zugang. Die Anwendung kennt ihn ohnehin, es muss nichts zusätzlich
// verteilt werden. Ohne ihn kommt ab dem Scharfschalten nur noch 401.
//
// platform bleibt offen: Das SDK setzt die Kennung des laufenden Systems ein.
// Sie wegzulassen wäre keine Option — der Server liefert dann ausschließlich
// plattformunabhängige Pakete.
var result = await client.CheckForUpdateAsync(
settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, currentVersion,
settings.UpdateChannel, cancellationToken: ct);
settings.UpdateChannel,
credentials: global::Deploymentcenter.Client.ReleaseCredentials
.FromLicenseKey(settings.LicenseKey),
cancellationToken: ct);
// Ein 401 ist kein Netzwerkfehler, sondern eine Aussage über die Lizenz.
// Ohne die Unterscheidung sucht man bei einem abgelaufenen Vertrag am Netz.
if (result.Unauthorized)
{
_logger.LogWarning(
"Update-Prüfung abgewiesen: {Message} – der Lizenzschlüssel öffnet die "
+ "Release-Ablage nicht (mehr).", result.Message);
return;
}
if (result.Error is not null)
{
@@ -247,6 +268,78 @@ public sealed class DeploymentcenterService : IAsyncDisposable
}
}
/// <summary>
/// Meldet dem Watchdog, dass das Herunterfahren gleich ein Update ist und kein
/// Ausfall. Muss <b>vor</b> dem Herunterfahren gerufen werden — die Meldung geht
/// beim Beenden des Heartbeats raus.
/// </summary>
public void AnnounceUpdate(string version)
=> _heartbeat?.AnnounceMaintenance($"Update auf {version} wird eingespielt.");
/// <summary>
/// Startet den <c>update-agent</c> und übergibt ihm das Feld.
///
/// <para><b>Beendet die Anwendung bewusst nicht selbst.</b> <c>LaunchUpdateAgent</c>
/// kann das (<c>exitCurrentApp</c>), täte es aber über
/// <see cref="Environment.Exit"/> — und das übergeht jedes Aufräumen: Der Watchdog
/// meldete nichts, Instanzdatenbank und Chatverläufe blieben auf halbem Weg stehen.
/// Der Aufrufer fährt deshalb erst geordnet herunter und beendet danach selbst.
/// Der Agent bekommt unsere Prozesskennung und wartet, bis wir wirklich weg
/// sind.</para>
/// </summary>
/// <returns>Der Grund, wenn es nicht losging; sonst <c>null</c>.</returns>
public string? StartUpdate(AppSettings settings, string currentVersion)
{
var agentPath = global::Deploymentcenter.Client.UpdateClient.ResolveAgentPath();
if (agentPath is null)
{
// Kein fest verdrahteter Name: Unter Linux trägt der Agent keine Endung.
_logger.LogWarning(
"Update angefordert, aber kein update-agent neben der Anwendung gefunden.");
return "Der update-agent wurde nicht gefunden. Er gehört neben die "
+ "Programmdatei; das Installationspaket bringt ihn normalerweise mit.";
}
try
{
var started = global::Deploymentcenter.Client.UpdateClient.LaunchUpdateAgent(
agentPath: agentPath,
projectId: LicenseInfo.ProductSlug,
channel: settings.UpdateChannel,
action: "update",
version: "latest",
exitCurrentApp: false,
currentVersion: currentVersion,
waitForCurrentProcess: true,
licenseKey: settings.LicenseKey,
// Ohne Signatur nicht installieren. Der SHA256 stammt aus derselben
// Quelle wie das Paket — wer den Webroot oder die FTP-Zugangsdaten
// kontrolliert, tauscht beide gemeinsam aus. Ausgerechnet auf dem Pfad,
// der anschließend fremden Code ausführt.
//
// Tragfähig, weil ab 0.1.2 jedes Release signiert ist. Ein Rücksprung
// auf 0.1.0 oder 0.1.1 bricht damit ab — das ist gewollt: Die beiden
// stammen aus der Zeit vor dem Signierschlüssel.
requireSignature: true);
if (!started)
return "Der update-agent ließ sich nicht starten.";
_logger.LogInformation("update-agent gestartet ({Path}), Anwendung wird beendet.",
agentPath);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "update-agent konnte nicht gestartet werden.");
return $"Der update-agent konnte nicht gestartet werden: {ex.Message}";
}
}
public async ValueTask DisposeAsync()
{
if (_heartbeat is not null)
@@ -36,7 +36,12 @@ public sealed class OpenRouterStatusService : IAsyncDisposable
/// <summary>Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt.</summary>
private readonly ConcurrentDictionary<string, byte> _modelsWithoutPricing = new();
private const double UsdToEur = 0.92;
/// <summary>
/// Grober Umrechnungskurs fuer die Anzeige. Oeffentlich, damit die Tokennutzungs-
/// Seite denselben Wert verwendet — zwei Kopien eines veraltenden Kurses waeren
/// zwei Gelegenheiten, verschiedene Betraege fuer dieselben Laeufe zu zeigen.
/// </summary>
public const double UsdToEur = 0.92;
public bool IsApiReachable { get; private set; }
public string StatusText { get; private set; } = "Prüfe...";
@@ -52,6 +52,46 @@ public sealed class AppSettings
[JsonPropertyName("logRefreshIntervalMs")]
public int LogRefreshIntervalMs { get; set; } = 500;
// ─── Zustand der Oberflaeche ───
//
// Bewusst hier und nicht in der Instanzkonfiguration: Das sind Gewohnheiten des
// Benutzers am Geraet, keine Eigenschaften der Instanz. Wer zwischen zwei Instanzen
// wechselt, will nicht jedes Mal wieder im Hellmodus mit ausgeklappter Leiste landen.
[Category("UI")]
[DisplayName("Farbschema")]
[Description("Light oder Dark. Umschaltbar ueber das Symbol in der Kopfzeile.")]
[JsonPropertyName("theme")]
public string Theme { get; set; } = "Light";
[Category("UI")]
[DisplayName("Seitenleiste eingeklappt")]
[Description("Merkt sich, ob die Navigation auf Symbolbreite zusammengeklappt war.")]
[JsonPropertyName("sidebarCollapsed")]
public bool SidebarCollapsed { get; set; }
// Die drei Teiler der Agenten-Seite, jeweils als Anteil 0..1 der Gesamtflaeche.
// Als Verhaeltnis und nicht in Pixeln: Sonst sitzt der Teiler nach einem Wechsel
// auf einen anderen Bildschirm an einer voellig anderen Stelle.
[Category("UI")]
[DisplayName("Agenten-Seite: Teiler waagerecht")]
[Description("Anteil des oberen Bereichs (Agentenliste und Einstellungen), 0.2 bis 0.8.")]
[JsonPropertyName("agentsSplitVertical")]
public double AgentsSplitVertical { get; set; } = 0.55;
[Category("UI")]
[DisplayName("Agenten-Seite: Teiler oben")]
[Description("Anteil der Agentenliste an der Breite des oberen Bereichs, 0.2 bis 0.8.")]
[JsonPropertyName("agentsSplitTopList")]
public double AgentsSplitTopList { get; set; } = 0.44;
[Category("UI")]
[DisplayName("Agenten-Seite: Teiler unten")]
[Description("Anteil der Werkzeugliste an der Breite des unteren Bereichs, 0.2 bis 0.8.")]
[JsonPropertyName("agentsSplitBottomList")]
public double AgentsSplitBottomList { get; set; } = 0.44;
[Category("API")]
[DisplayName("Status-Check-Intervall (Sek)")]
[Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]