WebUI: Update-Status anzeigen, installiert wird im Desktop

Umsetzung von #565.

GET /api/updates liefert die laufende Fassung (BuildInfo), die verfuegbare,
den Hinweis auf ein Sicherheitsupdate und den Changelog. Geprueft wird mit
demselben Aufruf wie beim Start des Desktops (UpdateClient gegen das
Deploymentcenter). Den Lizenzschluessel fuer den Paketabruf liest der Server,
er verlaesst ihn nicht.

Zwischenspeicher: UpdateStatusService haelt das Ergebnis 30 Minuten vor.
"Jetzt pruefen" umgeht das, aber fruehestens nach 60 Sekunden; bis dahin kommt
der vorige Stand mit der Wartezeit zurueck. Gleichzeitige Aufrufe loesen eine
einzige Pruefung aus. Ohne das fragte jeder Seitenaufruf, jedes offene
Browserfenster, das Deploymentcenter. Die Pruefung liegt hinter IUpdateChecker
und ist damit ohne Netz testbar.

Kein Installieren ueber das WebUI, und ein Test haelt fest, dass es keine
solche Route gibt: Die Installation startet den Update-Agent und beendet
danach die Anwendung und mit ihr den Webserver. Richards Vorgabe beschraenkt
das Beenden ueber das WebUI auf das Trading (#563).

Oberflaeche:
- Karte "Version & Updates" in den Einstellungen, mit "Jetzt pruefen".
- Im Dashboard ein Banner wie im Desktop-Launcher, sobald ein Update vorliegt.
  Er verweist zur Installation auf den Desktop.

Nicht durch Unit-Tests abgedeckt: DeploymentcenterUpdateChecker selbst, weil
er ueber das Netz geht. Er wird auf der VM gegen das echte Deploymentcenter
geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-13 14:20:07 +02:00
co-authored by Claude Opus 5
parent 0067e0b761
commit bc96807d81
6 changed files with 417 additions and 0 deletions
@@ -0,0 +1,138 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Deploymentcenter.Client;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Security;
using PolyTraderSharp.Models;
namespace PolyTrader.Web.Updates
{
/// <summary>Ergebnis einer Update-Prüfung, wie das WebUI es anzeigt (#565).</summary>
public sealed record UpdateCheckSnapshot(
DateTime CheckedAtUtc,
bool Enabled,
bool UpdateAvailable,
bool Unauthorized,
string? LatestVersion,
string? Changelog,
bool IsCritical,
string? Error);
/// <summary>Fragt, ob es eine neuere Fassung gibt. Hinter einer Schnittstelle, damit sich die Zwischenspeicherung ohne Netz prüfen lässt.</summary>
public interface IUpdateChecker
{
Task<UpdateCheckSnapshot> CheckAsync(CancellationToken ct);
}
/// <summary>
/// Hält das letzte Prüfergebnis vor (#565).
///
/// <para>Ohne diesen Zwischenspeicher fragte jeder Seitenaufruf des WebUI das Deploymentcenter – bei
/// mehreren offenen Browserfenstern ein Mehrfaches dessen, was der Desktop mit einer Prüfung beim
/// Start braucht. "Jetzt prüfen" umgeht den Zwischenspeicher, aber frühestens nach
/// <see cref="MinManualInterval"/>; ein Doppelklick oder ein Skript in einer Schleife soll keine
/// Anfrageflut auslösen.</para>
/// </summary>
public sealed class UpdateStatusService
{
public static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30);
public static readonly TimeSpan MinManualInterval = TimeSpan.FromSeconds(60);
private readonly IUpdateChecker _checker;
private readonly Func<DateTime> _utcNow;
private readonly SemaphoreSlim _gate = new(1, 1);
private UpdateCheckSnapshot? _last;
public UpdateStatusService(IUpdateChecker checker, Func<DateTime>? utcNow = null)
{
_checker = checker;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
}
/// <summary>
/// Liefert das Ergebnis, bei Bedarf frisch geprüft. <c>RetryAfter</c> ist gesetzt, wenn eine erzwungene
/// Prüfung zu früh kam und stattdessen der vorige Stand geliefert wird.
/// </summary>
public async Task<(UpdateCheckSnapshot Snapshot, bool FromCache, TimeSpan? RetryAfter)> GetAsync(bool force, CancellationToken ct)
{
// Eine Prüfung zur Zeit: Gleichzeitige Aufrufe warten und bekommen danach das frische Ergebnis.
await _gate.WaitAsync(ct);
try
{
var now = _utcNow();
if (_last != null)
{
var age = now - _last.CheckedAtUtc;
if (!force && age < CacheDuration)
return (_last, true, null);
if (force && age < MinManualInterval)
return (_last, true, MinManualInterval - age);
}
_last = await _checker.CheckAsync(ct);
return (_last, false, null);
}
finally
{
_gate.Release();
}
}
}
/// <summary>
/// Prüft gegen das Deploymentcenter – derselbe Aufruf wie beim Start des Desktops (LauncherWindow).
/// Der Lizenzschlüssel für den Paketabruf wird hier gelesen und verlässt den Server nicht.
/// </summary>
internal sealed class DeploymentcenterUpdateChecker : IUpdateChecker
{
private readonly IServiceProvider _services;
public DeploymentcenterUpdateChecker(IServiceProvider services) => _services = services;
public async Task<UpdateCheckSnapshot> CheckAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var dc = _services.GetService<DeploymentcenterOptions>();
if (dc == null || !dc.UpdateCheckEnabled || string.IsNullOrWhiteSpace(dc.BaseUrl))
return new UpdateCheckSnapshot(now, false, false, false, null, null, false,
"Die Update-Prüfung ist nicht eingerichtet (Deploymentcenter: UpdateCheckEnabled, BaseUrl).");
string? licenseKey = null;
try
{
var settings = _services.GetService<ServerSettingsFile>()?.Read() ?? _services.GetService<ServerSettings>();
licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty);
}
catch
{
// Nicht lesbarer Schlüssel: Die Prüfung läuft ohne Zugangsdaten weiter, wie im Desktop.
}
try
{
var result = await new UpdateClient().CheckForUpdateAsync(
baseUrl: dc.BaseUrl,
projectId: dc.ProjectSlug,
currentVersion: BuildInfo.Version,
channel: dc.Channel,
credentials: ReleaseCredentials.FromLicenseKey(licenseKey));
if (result.Unauthorized)
return new UpdateCheckSnapshot(now, true, false, true, null, null, false,
"Die Lizenz trägt nicht für den Paketabruf.");
bool available = result.UpdateAvailable && result.LatestRelease != null;
return new UpdateCheckSnapshot(now, true, available, false,
result.LatestRelease?.Version, result.LatestRelease?.Changelog, available && result.IsCritical, null);
}
catch (Exception ex)
{
return new UpdateCheckSnapshot(now, true, false, false, null, null, false,
$"Deploymentcenter nicht erreichbar: {ex.Message}");
}
}
}
}