Files
PolyTraderSharp/src/PolyTrader.Web/Updates/UpdateStatusService.cs
T
RichardandClaude Opus 5 b4d8a2a647
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Update-Pruefung regelmaessig und ab Werk an, Installation nur auf Klick
Richards Vorgabe vom 14.09.2026 (#587): Updates nie automatisch installieren,
aber regelmaessig pruefen und darauf hinweisen.

Bisher pruefte der Desktop genau einmal beim Start, das WebUI nur beim
Seitenaufruf, und ab Werk gar nicht (UpdateCheckEnabled=false). Eine
wochenlang laufende oder frisch installierte Anwendung erfuhr nie von einem
neuen Release.

- UpdateCheckEnabled ab Werk true, neu UpdateCheckIntervalHours (6, 1..168)
- UpdateCheckBackgroundService: erste Pruefung nach 30 s, dann im Takt; laeuft
  auch ohne WebUI und kopflos; Hinweis im Log einmal je neuer Fassung
- Registrierung ueber AddPolyTraderUpdateCheck, nicht mehr am WebUI
- Desktop-Banner haengt an UpdateStatusService.Checked; "Spaeter" blendet bis
  zur naechsten neueren Fassung aus
- WebUI-Banner holt alle 10 Minuten den vorgehaltenen Stand

Auf der Linux-VM geprueft: 0.1.9 ohne Handeinstellung erkennt 0.2.0, ein
Hinweis im Log, Banner erscheint, "Spaeter" blendet aus, Beenden in 0,1 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:16:33 +02:00

153 lines
6.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>Letztes Prüfergebnis, <c>null</c> vor der ersten Prüfung.</summary>
public UpdateCheckSnapshot? Last => _last;
/// <summary>
/// Feuert nach jeder frischen Prüfung, nicht bei einer Antwort aus dem Zwischenspeicher. Der Desktop hängt
/// sein Update-Banner daran - geprüft wird an genau einer Stelle, angezeigt an zweien.
/// </summary>
public event Action<UpdateCheckSnapshot>? Checked;
/// <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.
UpdateCheckSnapshot fresh;
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);
}
fresh = await _checker.CheckAsync(ct);
_last = fresh;
}
finally
{
_gate.Release();
}
// Ausserhalb der Sperre: Ein Empfaenger, der selbst GetAsync ruft, soll sich nicht selbst blockieren.
Checked?.Invoke(fresh);
return (fresh, false, null);
}
}
/// <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}");
}
}
}
}