Update-Pruefung regelmaessig und ab Werk an, Installation nur auf Klick
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s

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>
This commit is contained in:
Richard
2026-09-14 12:16:33 +02:00
co-authored by Claude Opus 5
parent 14c89aea7d
commit b4d8a2a647
9 changed files with 241 additions and 58 deletions
+3
View File
@@ -368,6 +368,9 @@ namespace PolyTrader.App.Avalonia
services.AddHostedService(sp => sp.GetRequiredService<WatchdogHeartbeatService>());
services.AddHostedService(sp => sp.GetRequiredService<DeploymentcenterErrorReporter>());
// #587: regelmäßige Update-Prüfung mit Hinweis - installiert nie selbst.
PolyTrader.Web.Updates.UpdateCheckServiceCollectionExtensions.AddPolyTraderUpdateCheck(services);
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
services.AddSingleton<AvaloniaUiHost>();
})
@@ -20,6 +20,7 @@ using PolyTrader.Core.Modularity;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Security;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTrader.Web.Updates;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
@@ -51,8 +52,11 @@ namespace PolyTrader.App.Avalonia.Views
private int _statusTicks;
/// <summary>Ergebnis der letzten Update-Prüfung – für den Klick auf „Jetzt installieren …".</summary>
private UpdateCheckResult? _updateCheck;
/// <summary>Fassung aus der letzten Update-Prüfung – für den Klick auf „Jetzt installieren …".</summary>
private string? _updateVersion;
/// <summary>Mit „Später" ausgeblendete Fassung – das Banner kommt erst für eine noch neuere wieder.</summary>
private string? _dismissedVersion;
public LauncherWindow() => AvaloniaXamlLoader.Load(this);
@@ -84,16 +88,25 @@ namespace PolyTrader.App.Avalonia.Views
this.FindControl<Button>("btnInstallUpdate")!.Click += async (_, _) => await InstallUpdateAsync();
this.FindControl<Button>("btnDismissUpdate")!.Click += (_, _) =>
{
_dismissedVersion = _updateVersion;
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = false;
};
_statusTimer.Tick += (_, _) => UpdateStatus();
_statusTimer.Start();
UpdateStatus();
RefreshData();
// D-4: einmalige, stille Prüfung beim Start - fire-and-forget, darf den Start nie
// verzögern oder blockieren. Nur aktiv, wenn Deploymentcenter:UpdateCheckEnabled gesetzt ist.
_ = CheckForUpdateAsync();
// #587: Geprüft wird regelmäßig im Hintergrund (UpdateCheckBackgroundService), hier nur angezeigt.
// Bis zum 14.09.2026 prüfte diese Seite genau einmal beim Start - eine wochenlang laufende
// Anwendung erfuhr nie von einem neuen Release. Kein Abmelden, siehe ThemeChanged unten.
var updates = services.GetService<UpdateStatusService>();
if (updates != null)
{
updates.Checked += OnUpdateChecked;
if (updates.Last is { } last) OnUpdateChecked(last);
}
// Die Umschalterflaechen werden im Code gesetzt und folgen dem Thema nicht von selbst.
// Kein Abmelden: die Seite lebt in der Einfenster-Shell bis zum Programmende, ein
@@ -185,56 +198,29 @@ namespace PolyTrader.App.Avalonia.Views
// ===== Update-Prüfung (D-4) =====
/// <summary>
/// Prüft beim Start still auf ein neues Release. Installiert NIE automatisch (Leitentscheidung
/// 4 aus dem Umsetzungsplan) - ein `is_critical`-Release ändert nur den Hinweistext, nicht das
/// Verhalten. Wirft nie: ein nicht erreichbares Deploymentcenter darf den Start nicht stören.
/// Zeigt das Ergebnis einer Hintergrundprüfung an. Installiert NIE automatisch (Leitentscheidung 4,
/// bestätigt am 14.09.2026) - ein `is_critical`-Release ändert nur den Hinweistext, nicht das Verhalten.
/// Kein Update, Fehler oder fehlende Lizenz blenden das Banner aus; die Einzelheiten stehen im WebUI
/// unter Einstellungen → Version &amp; Updates.
/// </summary>
private async Task CheckForUpdateAsync()
private void OnUpdateChecked(UpdateCheckSnapshot snapshot)
{
try
Dispatcher.UIThread.Post(() =>
{
var dcOptions = _services.GetService<DeploymentcenterOptions>();
if (dcOptions == null || !dcOptions.UpdateCheckEnabled || string.IsNullOrWhiteSpace(dcOptions.BaseUrl))
return;
var settings = _services.GetService<ServerSettings>();
string? licenseKey = null;
try { licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); }
catch { /* nicht lesbarer Schlüssel -> Prüfung läuft ohne Zugangsdaten weiter */ }
var updateClient = new UpdateClient();
var result = await updateClient.CheckForUpdateAsync(
baseUrl: dcOptions.BaseUrl,
projectId: dcOptions.ProjectSlug,
currentVersion: BuildInfo.Version,
channel: dcOptions.Channel,
credentials: ReleaseCredentials.FromLicenseKey(licenseKey));
if (result.Unauthorized)
var banner = this.FindControl<Border>("pnlUpdateBanner")!;
if (!snapshot.UpdateAvailable || string.IsNullOrEmpty(snapshot.LatestVersion))
{
// Kein Netzwerkfehler: die Lizenz trägt nicht mehr für den Paketabruf. Kein
// Alarm im Launcher nötig - die Lizenzprüfung selbst (D-2) hat das schon gemeldet.
_updateVersion = null;
banner.IsVisible = false;
return;
}
if (!result.UpdateAvailable || result.LatestRelease == null) return;
_updateCheck = result;
string version = result.LatestRelease.Version;
string critical = result.IsCritical ? " (empfohlenes Sicherheitsupdate)" : "";
Dispatcher.UIThread.Post(() =>
{
this.FindControl<TextBlock>("lblUpdateInfo")!.Text =
$"Update verfügbar: v{version}{critical}. {result.LatestRelease.Changelog}".TrimEnd();
this.FindControl<Border>("pnlUpdateBanner")!.IsVisible = true;
});
}
catch
{
// Update-Prüfung darf den Start nie stören - stiller Rückzug, kein Log-Spam beim
// vermutlich häufigsten Fall (Deploymentcenter nicht erreichbar).
}
_updateVersion = snapshot.LatestVersion;
string critical = snapshot.IsCritical ? " (empfohlenes Sicherheitsupdate)" : "";
this.FindControl<TextBlock>("lblUpdateInfo")!.Text =
$"Update verfügbar: v{snapshot.LatestVersion}{critical}. {snapshot.Changelog}".TrimEnd();
banner.IsVisible = !string.Equals(_dismissedVersion, snapshot.LatestVersion, StringComparison.OrdinalIgnoreCase);
});
}
/// <summary>
@@ -244,7 +230,7 @@ namespace PolyTrader.App.Avalonia.Views
/// </summary>
private async Task InstallUpdateAsync()
{
if (_updateCheck?.LatestRelease == null) return;
if (_updateVersion == null) return;
var dcOptions = _services.GetService<DeploymentcenterOptions>();
if (dcOptions == null) return;
@@ -259,7 +245,7 @@ namespace PolyTrader.App.Avalonia.Views
}
bool confirmed = await DialogWindow.Confirm(this, "Update installieren",
$"Version {_updateCheck.LatestRelease.Version} herunterladen und PolyTrader danach " +
$"Version {_updateVersion} herunterladen und PolyTrader danach " +
"neu starten?\n\nOffene Vorgänge werden dabei genauso geordnet beendet wie über „Beenden\".",
"Installieren");
if (!confirmed) return;
+3 -1
View File
@@ -15,7 +15,9 @@
"WatchdogIntervalSeconds": 60,
"WatchdogEnabled": false,
"ErrorReportingEnabled": false,
"UpdateCheckEnabled": false
"_UpdateCheck_comment": "Prueft regelmaessig auf ein neues Release und weist darauf hin. Installiert wird nie automatisch, nur auf Klick im Desktop.",
"UpdateCheckEnabled": true,
"UpdateCheckIntervalHours": 6
},
"Logging": {
"LogLevel": {
@@ -54,7 +54,14 @@ namespace PolyTrader.Core.Configuration
/// <summary>Ungefangene Fehler an /api/errors/v1/report melden.</summary>
public bool ErrorReportingEnabled { get; set; } = false;
/// <summary>Beim Start auf ein neues Release prüfen (installiert nie automatisch).</summary>
public bool UpdateCheckEnabled { get; set; } = false;
/// <summary>
/// Regelmäßig auf ein neues Release prüfen und darauf hinweisen. Installiert nie automatisch - das
/// geschieht nur auf Klick im Desktop. Ab Werk an (Richard, 14.09.2026, #587): Vorher stand hier
/// <c>false</c>, und eine frisch installierte Anwendung erfuhr nie von einem neuen Release.
/// </summary>
public bool UpdateCheckEnabled { get; set; } = true;
/// <summary>Abstand der Update-Prüfungen in Stunden, 1 bis 168.</summary>
public int UpdateCheckIntervalHours { get; set; } = 6;
}
}
@@ -25,9 +25,8 @@ namespace PolyTrader.Web.Hosting
{
services.AddSingleton(Options.Create(options));
// #565: Update-Status mit Zwischenspeicher - nicht jeder Seitenaufruf fragt das Deploymentcenter.
services.AddSingleton<IUpdateChecker, DeploymentcenterUpdateChecker>();
services.AddSingleton(sp => new UpdateStatusService(sp.GetRequiredService<IUpdateChecker>()));
// Update-Status (#565) registriert Program.cs über AddPolyTraderUpdateCheck - unabhängig vom WebUI,
// damit die regelmäßige Prüfung (#587) auch ohne WebUI läuft.
});
hostBuilder.ConfigureWebHostDefaults(webBuilder =>
@@ -0,0 +1,95 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PolyTrader.Core.Configuration;
using PolyTraderSharp.Services;
namespace PolyTrader.Web.Updates
{
/// <summary>
/// Prüft regelmäßig auf ein neues Release und weist darauf hin (#587).
///
/// <para><b>Installiert nie.</b> Richards Vorgabe vom 14.09.2026: Updates werden nicht automatisch
/// eingespielt, die Prüfung mit Hinweis läuft aber von selbst. Installiert wird nur auf Klick im Desktop
/// ("Jetzt installieren …"), weil das die Anwendung beendet.</para>
///
/// <para>Bis dahin prüfte der Desktop genau einmal beim Start und das WebUI nur, wenn jemand die Seite
/// aufrief. Eine Anwendung, die wochenlang läuft - der Normalfall im Betrieb -, erfuhr so nie von einem
/// neuen Release. Der Dienst läuft auch ohne WebUI und im kopflosen Betrieb; das Ergebnis landet im
/// gemeinsamen <see cref="UpdateStatusService"/>, an dem Desktop-Banner und WebUI hängen.</para>
/// </summary>
public sealed class UpdateCheckBackgroundService : BackgroundService
{
/// <summary>Erste Prüfung kurz nach dem Start - nicht sofort, damit sie den Start nicht belastet.</summary>
public static readonly TimeSpan StartDelay = TimeSpan.FromSeconds(30);
private readonly UpdateStatusService _status;
private readonly TerminalLogger _logger;
private readonly TimeSpan _interval;
private string? _announcedVersion;
public UpdateCheckBackgroundService(UpdateStatusService status, TerminalLogger logger, DeploymentcenterOptions? options)
{
_status = status;
_logger = logger;
_interval = Interval(options);
}
/// <summary>Takt aus <see cref="DeploymentcenterOptions.UpdateCheckIntervalHours"/>, auf 1 bis 168 Stunden begrenzt.</summary>
public static TimeSpan Interval(DeploymentcenterOptions? options) =>
TimeSpan.FromHours(Math.Clamp(options?.UpdateCheckIntervalHours ?? 6, 1, 168));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(StartDelay, stoppingToken).ConfigureAwait(false);
while (!stoppingToken.IsCancellationRequested)
{
await CheckOnceAsync(stoppingToken).ConfigureAwait(false);
await Task.Delay(_interval, stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Herunterfahren
}
}
/// <summary>
/// Eine Prüfung. Der Hinweis im Log erscheint einmal je neuer Fassung, nicht bei jeder Prüfung - sonst
/// stünde dieselbe Zeile alle paar Stunden im Log, bis jemand installiert.
/// </summary>
public async Task CheckOnceAsync(CancellationToken ct)
{
var (snapshot, _, _) = await _status.GetAsync(force: false, ct).ConfigureAwait(false);
if (!snapshot.UpdateAvailable || string.IsNullOrEmpty(snapshot.LatestVersion)) return;
if (string.Equals(snapshot.LatestVersion, _announcedVersion, StringComparison.OrdinalIgnoreCase)) return;
_announcedVersion = snapshot.LatestVersion;
string critical = snapshot.IsCritical ? " (empfohlenes Sicherheitsupdate)" : "";
_logger.Info($"🔔 Update verfügbar: v{snapshot.LatestVersion}{critical}. Installiert wird nur auf Wunsch - " +
"im Desktop über „Jetzt installieren …“ auf der Startseite.");
}
}
public static class UpdateCheckServiceCollectionExtensions
{
/// <summary>
/// Update-Prüfung für Desktop, WebUI und kopflosen Betrieb. Unabhängig davon, ob das WebUI läuft - bis
/// zum 14.09.2026 hing die Registrierung am WebUI, ohne WebUI gab es keine regelmäßige Prüfung.
/// </summary>
public static IServiceCollection AddPolyTraderUpdateCheck(this IServiceCollection services)
{
services.AddSingleton<IUpdateChecker, DeploymentcenterUpdateChecker>();
services.AddSingleton(sp => new UpdateStatusService(sp.GetRequiredService<IUpdateChecker>()));
services.AddHostedService(sp => new UpdateCheckBackgroundService(
sp.GetRequiredService<UpdateStatusService>(),
sp.GetRequiredService<TerminalLogger>(),
sp.GetService<DeploymentcenterOptions>()));
return services;
}
}
}
@@ -52,6 +52,15 @@ namespace PolyTrader.Web.Updates
_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.
@@ -59,6 +68,7 @@ namespace PolyTrader.Web.Updates
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
{
@@ -72,13 +82,17 @@ namespace PolyTrader.Web.Updates
return (_last, true, MinManualInterval - age);
}
_last = await _checker.CheckAsync(ct);
return (_last, false, null);
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);
}
}
+3
View File
@@ -9,6 +9,9 @@ document.addEventListener('DOMContentLoaded', () => {
applyThemeChoice(currentThemeChoice());
supChatWelcome = document.getElementById('supChatMessages')?.innerHTML ?? '';
loadUpdateStatus(false); // #565: Banner im Dashboard; der Server haelt das Ergebnis vor
// #587: Der Server prueft regelmaessig selbst; die Seite holt nur den vorgehaltenen Stand nach,
// damit ein lange offenes Browserfenster ein neues Release auch mitbekommt.
setInterval(() => loadUpdateStatus(false), 10 * 60 * 1000);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('modalStopTrading')?.style.display === 'flex') closeStopTradingModal();
});
@@ -104,6 +104,80 @@ namespace PolyTrader.Tests.WebUi
Assert.Equal(1, checker.Calls);
}
[Fact]
public async Task Checked_feuert_nur_bei_frischer_Pruefung()
{
var clock = new Clock();
var checker = new FakeChecker(() => clock.Now);
var service = new UpdateStatusService(checker, () => clock.Now);
int events = 0;
service.Checked += _ => events++;
await service.GetAsync(false, CancellationToken.None);
await service.GetAsync(false, CancellationToken.None); // aus dem Zwischenspeicher
Assert.Equal(1, events);
Assert.Equal("0.2.0", service.Last?.LatestVersion);
}
[Fact]
public async Task Hintergrundpruefung_meldet_jede_neue_Fassung_genau_einmal_und_installiert_nichts()
{
var clock = new Clock();
var checker = new FakeChecker(() => clock.Now);
var status = new UpdateStatusService(checker, () => clock.Now);
var logger = new PolyTraderSharp.Services.TerminalLogger();
var notices = new System.Collections.Generic.List<string>();
logger.OnLogMessage += (_, e) => { if (e.Message.Contains("Update verfügbar")) notices.Add(e.Message); };
var background = new UpdateCheckBackgroundService(status, logger, null);
await background.CheckOnceAsync(CancellationToken.None);
clock.Now = clock.Now + UpdateStatusService.CacheDuration;
await background.CheckOnceAsync(CancellationToken.None); // dieselbe Fassung: kein zweiter Hinweis
checker.Result = checker.Result with { LatestVersion = "0.3.0" };
clock.Now = clock.Now + UpdateStatusService.CacheDuration;
await background.CheckOnceAsync(CancellationToken.None);
Assert.Equal(2, notices.Count);
Assert.Contains("v0.2.0", notices[0]);
Assert.Contains("v0.3.0", notices[1]);
Assert.All(notices, n => Assert.Contains("nur auf Wunsch", n));
}
[Fact]
public async Task Ohne_Update_kein_Hinweis()
{
var clock = new Clock();
var checker = new FakeChecker(() => clock.Now);
checker.Result = checker.Result with { UpdateAvailable = false, LatestVersion = null };
var logger = new PolyTraderSharp.Services.TerminalLogger();
int notices = 0;
logger.OnLogMessage += (_, e) => { if (e.Message.Contains("Update verfügbar")) notices++; };
await new UpdateCheckBackgroundService(new UpdateStatusService(checker, () => clock.Now), logger, null)
.CheckOnceAsync(CancellationToken.None);
Assert.Equal(0, notices);
}
[Theory]
[InlineData(null, 6)]
[InlineData(0, 1)]
[InlineData(12, 12)]
[InlineData(10000, 168)]
public void Pruefintervall_ist_begrenzt(int? hours, int expected)
{
var options = hours == null ? null : new PolyTrader.Core.Configuration.DeploymentcenterOptions { UpdateCheckIntervalHours = hours.Value };
Assert.Equal(TimeSpan.FromHours(expected), UpdateCheckBackgroundService.Interval(options));
}
[Fact]
public void Update_Pruefung_ist_ab_Werk_an()
{
Assert.True(new PolyTrader.Core.Configuration.DeploymentcenterOptions().UpdateCheckEnabled);
}
[Fact]
public async Task Route_liefert_Fassung_Stand_und_verweist_zur_Installation_auf_den_Desktop()
{