feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
using ClawdDotNet.App.Settings;
|
||||
using Deploymentcenter.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters.
|
||||
///
|
||||
/// <para><b>Nur ein Urteil sperrt.</b> Das SDK trennt seit 2.1 zwei Dinge, die vorher
|
||||
/// beide als „Lizenz ungültig" ankamen: eine Aussage des Servers über die Lizenz
|
||||
/// (<c>revoked</c>, <c>expired</c>, <c>not_found</c>, <c>activation_limit</c>,
|
||||
/// <c>suspended</c>, <c>clock_rollback</c>) und ein gescheiterter Versuch, überhaupt
|
||||
/// eine zu bekommen (<see cref="LicenseValidationResult.IsTransient"/>). Nur das Urteil
|
||||
/// beendet die Anwendung. Ein Serverausfall darf nicht jede Installation gleichzeitig
|
||||
/// aussperren.</para>
|
||||
///
|
||||
/// <para>Die Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung
|
||||
/// einen mit AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an und
|
||||
/// trägt damit über Ausfälle hinweg — begrenzt durch <c>cache_ttl_hours</c> des Projekts
|
||||
/// (Vorgabe 168 h), nicht mehr durch das Ablaufdatum der Lizenz.</para>
|
||||
///
|
||||
/// <para><b>Was diese Fassung nicht kann.</b> Deaktivieren (Aktivierungsplatz freigeben)
|
||||
/// verlangt den <c>shared_key</c> des Servers. Der gehört nicht in eine ausgelieferte
|
||||
/// Anwendung, deshalb läuft der Weg über die Hardware-Liste im WebUI („Freigeben").
|
||||
/// Eine Signaturprüfung findet nicht statt — siehe <see cref="LicenseInfo"/>.</para>
|
||||
/// </summary>
|
||||
public sealed class LicenseGate
|
||||
{
|
||||
private readonly SettingsManager _settings;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ILicensePrompt _prompt;
|
||||
private readonly LicenseClient _client;
|
||||
private readonly string _serverUrl;
|
||||
|
||||
private string? _hardwareId;
|
||||
|
||||
public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_prompt = prompt;
|
||||
|
||||
_serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl)
|
||||
? LicenseInfo.DefaultServerUrl
|
||||
: settings.AppSettings.DeploymentcenterUrl.Trim();
|
||||
|
||||
// Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das
|
||||
// trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist.
|
||||
LicenseClient.DefaultAppVersion = ReleaseInfo.Version;
|
||||
|
||||
// Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze
|
||||
// von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der
|
||||
// Server nicht antwortete.
|
||||
_client = new LicenseClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True, wenn eine Serveradresse hinterlegt ist. Ohne sie wird die Prüfung
|
||||
/// übersprungen — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand etwas
|
||||
/// eintragen kann.
|
||||
/// </summary>
|
||||
public bool IsEnforcementConfigured => !string.IsNullOrWhiteSpace(_serverUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Die Hardware-ID v2 dieses Rechners. Einmal berechnet und behalten: Die Ermittlung
|
||||
/// liest unter Linux Dateien und zählt Netzwerkschnittstellen auf.
|
||||
/// </summary>
|
||||
public string HardwareId =>
|
||||
// Voll qualifiziert: Sonst zeigte der Name auf diese Eigenschaft selbst.
|
||||
_hardwareId ??= global::Deploymentcenter.Client.HardwareId
|
||||
.GetHardwareId(LicenseInfo.ProductSlug).HardwareId;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft den hinterlegten Schlüssel erneut — für die laufende Nachprüfung. Ein
|
||||
/// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet.
|
||||
/// </summary>
|
||||
public Task<LicenseValidationResult> RevalidateAsync(CancellationToken ct = default)
|
||||
=> ValidateAsync(_settings.AppSettings.LicenseKey, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Startprüfung. Fragt über <see cref="ILicensePrompt"/> nach, bis eine nutzbare
|
||||
/// Lizenz vorliegt, oder gibt <c>false</c> zurück — dann beendet der Aufrufer die
|
||||
/// Anwendung.
|
||||
/// </summary>
|
||||
public async Task<bool> RunStartupCheckAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnforcementConfigured)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Lizenzprüfung nicht konfiguriert (keine Deploymentcenter-URL) – übersprungen.");
|
||||
return true;
|
||||
}
|
||||
|
||||
var key = _settings.AppSettings.LicenseKey;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
key = await _prompt.RequestKeyAsync(HardwareId, null, null);
|
||||
if (key is null) return false;
|
||||
}
|
||||
|
||||
var result = await ValidateAsync(key, ct);
|
||||
|
||||
while (!result.IsValid)
|
||||
{
|
||||
// Kein Urteil, sondern ein gescheiterter Versuch: weiterlaufen. Ein anderer
|
||||
// Schlüssel würde daran nichts ändern, und danach zu fragen ließe den
|
||||
// Benutzer raten. Der Notausschalter greift, sobald der Server wieder
|
||||
// antwortet (LicenseWatch).
|
||||
if (result.IsTransient)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Lizenz nicht prüfbar ({Status}): {Message} – Start wird fortgesetzt.",
|
||||
result.Status, result.Message);
|
||||
|
||||
await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz",
|
||||
"Die Lizenz konnte nicht geprüft werden (Server nicht erreichbar oder "
|
||||
+ "Offline-Frist abgelaufen). ClawdDotNet läuft weiter und prüft später "
|
||||
+ "erneut.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result.Status == "clock_rollback")
|
||||
{
|
||||
// Bewusst ohne Details: Ein genauer Text wäre eine Bauanleitung.
|
||||
await _prompt.ShowErrorAsync("ClawdDotNet – Lizenz",
|
||||
"Die Lizenzprüfung konnte nicht abgeschlossen werden. "
|
||||
+ "Bitte den Support kontaktieren.");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Lizenz abgelehnt: {Status} – {Message} (Produkt {Slug}, Server {Server})",
|
||||
result.Status, result.Message, LicenseInfo.ProductSlug, _serverUrl);
|
||||
|
||||
var retry = await _prompt.RequestKeyAsync(HardwareId, DescribeProblem(result), key);
|
||||
if (retry is null) return false;
|
||||
|
||||
key = retry;
|
||||
result = await ValidateAsync(key, ct);
|
||||
}
|
||||
|
||||
_settings.AppSettings.LicenseKey = key;
|
||||
|
||||
try
|
||||
{
|
||||
_settings.Save();
|
||||
}
|
||||
catch (SettingsPersistenceException ex)
|
||||
{
|
||||
// Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja —
|
||||
// beim nächsten Start wird eben erneut gefragt.
|
||||
_logger.LogWarning(ex, "Lizenzschlüssel konnte nicht gespeichert werden");
|
||||
}
|
||||
|
||||
if (result.IsCached)
|
||||
{
|
||||
var until = Describe(result.CacheExpiresAt);
|
||||
|
||||
_logger.LogInformation("Lizenz offline gültig, Gnadenfrist bis {Until}.", until);
|
||||
|
||||
await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz",
|
||||
$"Lizenzserver nicht erreichbar. Offline gültig bis {until}.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<LicenseValidationResult> ValidateAsync(string key, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "not_found",
|
||||
Message = "Kein Lizenzschlüssel hinterlegt."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _client.ValidateAsync(
|
||||
LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Das SDK fängt Netz- und HTTP-Fehler selbst ab und liefert sie als
|
||||
// IsTransient. Was hier noch ankommt, ist unerwartet — und darf trotzdem
|
||||
// nicht als Urteil über die Lizenz gelten.
|
||||
_logger.LogError(ex, "Lizenzprüfung fehlgeschlagen.");
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "server_unavailable",
|
||||
Message = "Lizenz konnte nicht geprüft werden.",
|
||||
IsTransient = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(long? unixSeconds) =>
|
||||
unixSeconds is { } seconds and > 0
|
||||
? DateTimeOffset.FromUnixTimeSeconds(seconds).LocalDateTime.ToString("g")
|
||||
: "auf Weiteres";
|
||||
|
||||
private static string DescribeProblem(LicenseValidationResult result) => result.Status switch
|
||||
{
|
||||
"revoked" => "Diese Lizenz wurde widerrufen oder diese Hardware ist gesperrt.",
|
||||
"suspended" => "Diese Lizenz ist vorübergehend ausgesetzt.",
|
||||
"expired" => "Diese Lizenz ist abgelaufen.",
|
||||
"activation_limit" => "Das Aktivierungslimit dieser Lizenz ist erreicht. "
|
||||
+ "Ein Platz lässt sich im Deploymentcenter in der Hardware-Liste "
|
||||
+ "über \"Freigeben\" räumen.",
|
||||
// Der Server verwendet not_found für zwei verschiedene Dinge: unbekanntes
|
||||
// Projekt und unbekannter Schlüssel. Welches davon, steht nur in message —
|
||||
// deshalb wird die Serverantwort hier mitgegeben. Ohne sie sieht ein falsch
|
||||
// eingetragener Produkt-Slug wie ein vertippter Lizenzschlüssel aus, und man
|
||||
// sucht am falschen Ende.
|
||||
"not_found" => "Lizenzschlüssel oder Produkt unbekannt. Im Deploymentcenter muss "
|
||||
+ $"ein Projekt mit dem Slug \"{LicenseInfo.ProductSlug}\" angelegt "
|
||||
+ "sein und der Schlüssel dort hinterlegt."
|
||||
+ (string.IsNullOrWhiteSpace(result.Message)
|
||||
? ""
|
||||
: $" (Server: {result.Message})"),
|
||||
_ => string.IsNullOrWhiteSpace(result.Message)
|
||||
? "Die Lizenz ist nicht gültig."
|
||||
: result.Message
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user