Behebt eine Reihe zusammenhaengender Fehler im Update-Weg, die zusammen verhindert haben, fuer mehr als eine Plattform auszuliefern - und die im Fehlerfall halb aktualisierte Installationen hinterliessen. Server - Migration 009: Spalte platform samt neuem Unique-Key. Zuvor verdraengte das zuletzt veroeffentlichte Paket alle anderen Plattformen derselben Version, weil ON DUPLICATE KEY auf (slug, version, channel) griff. Ein Linux-System zog sich damit das Windows-Paket. - Aufloesungsregel: je Version das plattformgenaue Paket, sonst das plattformunabhaengige. Ein Client ohne Plattformangabe sieht ausschliesslich 'any' - lieber kein Update als das falsche. - manifest_json wird endlich befuellt; die Spalte blieb bisher immer leer, wodurch die API nie der Rueckfall sein konnte, als der sie gedacht war. - Releases werden serverseitig mit RSA-SHA256 signiert, neuer Endpunkt /api/updateservice/v1/pubkey. Bewusst kein HMAC: der Pruefende laeuft auf fremden Systemen und darf den Signierschluessel nicht besitzen. Packager - Bricht ab, statt die Versionshistorie zu verlieren. Schlug das Lesen der bestehenden latest.json fehl, ersetzte ein leeres catch die komplette Historie durch einen einzigen Eintrag - ohne jede Meldung. - Echte Glob-Muster. Zuvor trafen "logs/**" und "scratch/**" aus der mitgelieferten Beispielkonfiguration nie zu. - preservePatterns: Konfigurationsvorlagen werden ausgeliefert, ersetzen am Ziel aber keine vorhandene Datei. Eine settings.json mit Zugangsdaten ueberschrieb bisher beim Update die Konfiguration jedes Zielsystems. - Warnt vor Dateien, die nach Zugangsdaten aussehen und auf keiner Liste stehen. - Prueft --version gegen die Hauptassembly. Eine Abweichung fuehrte zu einer Endlosschleife: Clients aktualisieren, melden weiter die alte Version, halten das Release erneut fuer neu. - --platform mit Ableitung aus dem Publish-Pfad. Agent - Anwenden mit Plan, Backup und vollstaendigem Rollback. Die Stelle war als "Atomic Replace with Backup" kommentiert und war eine Kopierschleife. - Verwaiste Dateien werden entfernt, aber nur solche aus dem Manifest der Vorversion. Was nicht aus einem Release stammt, bleibt liegen. - Das laufende Agent-Binary wird zur Seite gelegt statt ueberschrieben. - API-Rueckfall in FetchManifestAsync; bisher nur im SDK vorhanden, weshalb die Anwendung "Update verfuegbar" und der Agent "kein Release" sagen konnte. - Installierte Version aus --current-version oder manifest.json statt des Textes "Unbekannt", der als 0 gelesen wurde und jede Version neuer erscheinen liess. Reparatur funktioniert damit auch ohne manifest.json. - Setzt das Ausfuehrungsbit fuer Linux-Pakete, die unter Windows gebaut wurden. SDK - ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes "update-agent.exe" wird unter Linux nie gefunden. - LaunchUpdateAgent uebergibt jetzt --restart (wurde nie uebergeben, die Anwendung blieb nach dem Update zu), --wait-for-pid (kein Wettlauf mehr mit dem Herunterfahren) und --platform. Enthaelt ausserdem die bislang nicht committete Arbeit an Watchdog, Lizenz- Client und cli/tick.php samt Migration 008; die betroffenen Dateien liessen sich nicht getrennt stagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
522 lines
19 KiB
C#
522 lines
19 KiB
C#
using System;
|
|
using System.Net.Http;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Deploymentcenter.Client;
|
|
|
|
public class LicenseValidationResult
|
|
{
|
|
public bool IsValid { get; set; }
|
|
public string Status { get; set; } = "unknown";
|
|
public string Message { get; set; } = string.Empty;
|
|
public string HardwareId { get; set; } = string.Empty;
|
|
public bool IsCached { get; set; }
|
|
|
|
/// <summary>Ablauf der Lizenz selbst (Unix-Zeit), nicht der des Caches.</summary>
|
|
public long? ExpiresAt { get; set; }
|
|
|
|
/// <summary>
|
|
/// Ablauf der Offline-Gnadenfrist (Unix-Zeit). Danach verlangt der Client
|
|
/// wieder eine erreichbare Gegenstelle, auch wenn die Lizenz laenger laeuft.
|
|
/// </summary>
|
|
public long? CacheExpiresAt { get; set; }
|
|
|
|
/// <summary>
|
|
/// Wahr, wenn kein Lizenzurteil vorliegt, sondern nur die Verbindung zum
|
|
/// Server gescheitert ist (Netzfehler, HTTP 429/5xx, unlesbare Antwort).
|
|
///
|
|
/// Ein solcher Zustand darf eine Anwendung nicht beenden. Nur ein vom
|
|
/// Server geliefertes Urteil (revoked, expired, not_found,
|
|
/// activation_limit, suspended) ist endgueltig.
|
|
/// </summary>
|
|
public bool IsTransient { get; set; }
|
|
}
|
|
|
|
public class LicenseClient
|
|
{
|
|
/// <summary>
|
|
/// Voreinstellung fuer app_version, wenn ein Aufrufer sie nicht je Aufruf
|
|
/// uebergibt. Einmal beim Start setzen, z. B. auf BuildInfo.Version.
|
|
/// </summary>
|
|
public static string? DefaultAppVersion { get; set; }
|
|
|
|
/// <summary>
|
|
/// Standard-Zeitgrenze fuer den intern erzeugten HttpClient. Ohne sie
|
|
/// stand eine Anwendung beim Start bis zu 100 Sekunden still, wenn der
|
|
/// Server nicht antwortete.
|
|
/// </summary>
|
|
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(15);
|
|
|
|
private static readonly Lazy<HttpClient> SharedHttpClient = new Lazy<HttpClient>(
|
|
() => new HttpClient { Timeout = DefaultTimeout },
|
|
LazyThreadSafetyMode.ExecutionAndPublication);
|
|
|
|
/// <summary>Standard-Gnadenfrist, wenn der Server keine TTL mitschickt.</summary>
|
|
private const int FallbackCacheTtlHours = 168;
|
|
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILicensePrompt _prompt;
|
|
|
|
/// <summary>
|
|
/// Ohne eigenen HttpClient wird eine gemeinsame Instanz mit
|
|
/// <see cref="DefaultTimeout"/> verwendet. Ein uebergebener HttpClient
|
|
/// wird nicht veraendert - dessen Zeitgrenze verantwortet der Aufrufer.
|
|
/// </summary>
|
|
public LicenseClient(HttpClient? httpClient = null, ILicensePrompt? prompt = null)
|
|
{
|
|
_httpClient = httpClient ?? SharedHttpClient.Value;
|
|
_prompt = prompt ?? new ConsoleLicensePrompt();
|
|
}
|
|
|
|
public Task<LicenseValidationResult> ValidateAsync(
|
|
string productSlug,
|
|
string licenseKey,
|
|
string serverBaseUrl,
|
|
CancellationToken cancellationToken)
|
|
=> ValidateAsync(productSlug, licenseKey, serverBaseUrl, null, cancellationToken);
|
|
|
|
/// <param name="appVersion">
|
|
/// Version der aufrufenden Anwendung. Landet in der Aktivierungsliste des
|
|
/// Deploymentcenters. Ohne Angabe wird <see cref="DefaultAppVersion"/> und
|
|
/// danach die Version der Startassembly verwendet.
|
|
/// </param>
|
|
public async Task<LicenseValidationResult> ValidateAsync(
|
|
string productSlug,
|
|
string licenseKey,
|
|
string serverBaseUrl,
|
|
string? appVersion = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
|
|
string? failureReason = null;
|
|
HttpResponseMessage? response = null;
|
|
|
|
try
|
|
{
|
|
var payload = new
|
|
{
|
|
product = productSlug,
|
|
license_key = licenseKey,
|
|
hardware_id = hwInfo.HardwareId,
|
|
legacy_hardware_id = hwInfo.LegacyHardwareId,
|
|
hwid_version = hwInfo.HwidVersion,
|
|
hwid_source = hwInfo.HwidSource,
|
|
platform = hwInfo.Platform,
|
|
hostname = Environment.MachineName,
|
|
app_version = ResolveAppVersion(appVersion),
|
|
nonce = Guid.NewGuid().ToString("N")
|
|
};
|
|
|
|
string jsonStr = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
|
|
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/validate";
|
|
|
|
response = await _httpClient.PostAsync(endpoint, content, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failureReason = ex.Message;
|
|
}
|
|
|
|
using (response)
|
|
{
|
|
if (response != null)
|
|
{
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
string resBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
|
var verdict = TryReadVerdict(resBody, productSlug, licenseKey, hwInfo, now);
|
|
|
|
if (verdict != null)
|
|
{
|
|
return verdict;
|
|
}
|
|
|
|
// Erfolgreiche Antwort, aber kein lesbares Urteil - etwa die
|
|
// Fehlerseite eines Proxys mit Statuscode 200. Das ist keine
|
|
// Aussage ueber die Lizenz, also wird sie auch nicht als
|
|
// solche behandelt.
|
|
failureReason = "Antwort des Servers war nicht auswertbar";
|
|
}
|
|
else
|
|
{
|
|
// WICHTIG: Ein HTTP-Fehler ist kein Lizenzurteil.
|
|
//
|
|
// Frueher lag der Cache-Zweig ausschliesslich im catch.
|
|
// Ein 429 (Drosselung) oder 500 warf keine Exception,
|
|
// sondern fiel aus dem Erfolgszweig heraus und endete als
|
|
// "unknown_error" - ohne den Cache auch nur zu befragen.
|
|
// Ein Serverfehler entzog damit die Lizenz, ein gezogenes
|
|
// Netzkabel nicht. Jetzt fuehrt jeder Nicht-Erfolg in
|
|
// denselben Offline-Zweig.
|
|
failureReason = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim();
|
|
}
|
|
}
|
|
}
|
|
|
|
return OfflineFallback(productSlug, hwInfo, now, failureReason ?? "Server nicht erreichbar");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wertet die Serverantwort aus. Liefert null, wenn sie kein lesbares
|
|
/// Lizenzurteil enthaelt - dann greift der Offline-Zweig.
|
|
/// </summary>
|
|
private static LicenseValidationResult? TryReadVerdict(
|
|
string resBody,
|
|
string productSlug,
|
|
string licenseKey,
|
|
HardwareIdResult hwInfo,
|
|
long now)
|
|
{
|
|
string status;
|
|
string message;
|
|
long? expiresAt;
|
|
int cacheTtlHours;
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(resBody);
|
|
var root = doc.RootElement;
|
|
|
|
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("status", out var sProp))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
status = sProp.GetString() ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(status))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
message = root.TryGetProperty("message", out var mProp) ? mProp.GetString() ?? "" : "";
|
|
expiresAt = root.TryGetProperty("expires_at", out var eProp) && eProp.ValueKind == JsonValueKind.Number
|
|
? eProp.GetInt64()
|
|
: (long?)null;
|
|
|
|
// Der Server bestimmt die Laenge der Offline-Gnadenfrist je Projekt.
|
|
// Vorher wurde das Feld ignoriert und stattdessen das Ablaufdatum
|
|
// der Lizenz eingetragen - bei einer Lizenz bis 2040 war die Frist
|
|
// praktisch unbegrenzt.
|
|
cacheTtlHours = root.TryGetProperty("cache_ttl_hours", out var tProp) && tProp.ValueKind == JsonValueKind.Number
|
|
? tProp.GetInt32()
|
|
: FallbackCacheTtlHours;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (cacheTtlHours <= 0)
|
|
{
|
|
cacheTtlHours = FallbackCacheTtlHours;
|
|
}
|
|
|
|
if (!status.Equals("valid", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new LicenseValidationResult
|
|
{
|
|
IsValid = false,
|
|
Status = status,
|
|
Message = message,
|
|
HardwareId = hwInfo.HardwareId,
|
|
IsCached = false,
|
|
ExpiresAt = expiresAt,
|
|
IsTransient = false
|
|
};
|
|
}
|
|
|
|
// Die Gnadenfrist endet mit der TTL - spaetestens aber mit der Lizenz.
|
|
long cacheExpiresAt = now + (long)cacheTtlHours * 3600L;
|
|
if (expiresAt.HasValue && expiresAt.Value > 0 && expiresAt.Value < cacheExpiresAt)
|
|
{
|
|
cacheExpiresAt = expiresAt.Value;
|
|
}
|
|
|
|
var cache = new LocalCacheData
|
|
{
|
|
SchemaVersion = StateStore.CurrentSchemaVersion,
|
|
ProductSlug = productSlug,
|
|
LicenseKey = licenseKey,
|
|
HardwareId = hwInfo.HardwareId,
|
|
Status = "valid",
|
|
IssuedAt = now,
|
|
ExpiresAt = expiresAt ?? 0,
|
|
CacheExpiresAt = cacheExpiresAt,
|
|
CacheTtlHours = cacheTtlHours,
|
|
MaxSeenTime = now,
|
|
Checksum = hwInfo.HardwareId
|
|
};
|
|
|
|
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
|
|
|
|
return new LicenseValidationResult
|
|
{
|
|
IsValid = true,
|
|
Status = status,
|
|
Message = message,
|
|
HardwareId = hwInfo.HardwareId,
|
|
IsCached = false,
|
|
ExpiresAt = expiresAt,
|
|
CacheExpiresAt = cacheExpiresAt,
|
|
IsTransient = false
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gemeinsamer Zweig fuer jeden Fall, in dem der Server kein Urteil
|
|
/// geliefert hat: Netzfehler, HTTP-Fehler, unlesbare Antwort.
|
|
/// </summary>
|
|
private static LicenseValidationResult OfflineFallback(
|
|
string productSlug,
|
|
HardwareIdResult hwInfo,
|
|
long now,
|
|
string reason)
|
|
{
|
|
var cache = StateStore.Load(productSlug, hwInfo.HardwareId);
|
|
|
|
if (cache != null && cache.Status == "valid")
|
|
{
|
|
// Rueckdrehen der Systemuhr erkennen
|
|
if (now < cache.MaxSeenTime)
|
|
{
|
|
return new LicenseValidationResult
|
|
{
|
|
IsValid = false,
|
|
Status = "clock_rollback",
|
|
Message = "System clock rollback detected! Online verification required.",
|
|
HardwareId = hwInfo.HardwareId,
|
|
IsTransient = false
|
|
};
|
|
}
|
|
|
|
long cacheExpiresAt = ResolveCacheExpiry(cache);
|
|
|
|
if (cacheExpiresAt > 0 && now > cacheExpiresAt)
|
|
{
|
|
return new LicenseValidationResult
|
|
{
|
|
IsValid = false,
|
|
Status = "cache_expired",
|
|
Message = $"Offline-Gnadenfrist abgelaufen, Server nicht erreichbar ({reason}).",
|
|
HardwareId = hwInfo.HardwareId,
|
|
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
|
|
CacheExpiresAt = cacheExpiresAt,
|
|
IsTransient = true
|
|
};
|
|
}
|
|
|
|
// Auch offline darf eine abgelaufene Lizenz nicht weiterlaufen.
|
|
if (cache.ExpiresAt > 0 && now > cache.ExpiresAt)
|
|
{
|
|
return new LicenseValidationResult
|
|
{
|
|
IsValid = false,
|
|
Status = "expired",
|
|
Message = "Cached license has expired.",
|
|
HardwareId = hwInfo.HardwareId,
|
|
ExpiresAt = cache.ExpiresAt,
|
|
IsTransient = false
|
|
};
|
|
}
|
|
|
|
cache.MaxSeenTime = now;
|
|
StateStore.Save(productSlug, hwInfo.HardwareId, cache);
|
|
|
|
return new LicenseValidationResult
|
|
{
|
|
IsValid = true,
|
|
Status = "valid_offline",
|
|
Message = $"License validated via secure offline cache ({reason}).",
|
|
HardwareId = hwInfo.HardwareId,
|
|
IsCached = true,
|
|
ExpiresAt = cache.ExpiresAt > 0 ? cache.ExpiresAt : null,
|
|
CacheExpiresAt = cacheExpiresAt,
|
|
IsTransient = false
|
|
};
|
|
}
|
|
|
|
return new LicenseValidationResult
|
|
{
|
|
IsValid = false,
|
|
Status = "server_unavailable",
|
|
Message = $"Server communication error and no valid cache available: {reason}",
|
|
HardwareId = hwInfo.HardwareId,
|
|
IsTransient = true
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Caches aus Schema 2 kennen kein CacheExpiresAt. Fuer sie wird die Frist
|
|
/// aus dem Ausstellungszeitpunkt und der Standard-TTL abgeleitet, statt
|
|
/// unbegrenzt zu gelten.
|
|
/// </summary>
|
|
private static long ResolveCacheExpiry(LocalCacheData cache)
|
|
{
|
|
if (cache.CacheExpiresAt > 0)
|
|
{
|
|
return cache.CacheExpiresAt;
|
|
}
|
|
|
|
int ttlHours = cache.CacheTtlHours > 0 ? cache.CacheTtlHours : FallbackCacheTtlHours;
|
|
return cache.IssuedAt > 0 ? cache.IssuedAt + (long)ttlHours * 3600L : 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Liest den zuletzt erfolgreich geprueften Schluessel aus dem lokalen
|
|
/// Cache. Damit laesst sich ein Schalter wie --license-status oder ein
|
|
/// stiller Neustart bauen, ohne den Schluessel ein zweites Mal abzulegen.
|
|
/// Liefert null, wenn kein brauchbarer Cache vorliegt.
|
|
/// </summary>
|
|
public static string? TryGetCachedKey(string productSlug)
|
|
{
|
|
var cache = TryGetCachedState(productSlug);
|
|
return string.IsNullOrWhiteSpace(cache?.LicenseKey) ? null : cache!.LicenseKey;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Vollstaendiger Cache-Eintrag fuer Statusausgaben (Ablauf, Gnadenfrist,
|
|
/// Hardware-ID). Liefert null, wenn keiner vorliegt oder er nicht zu dieser
|
|
/// Maschine gehoert.
|
|
/// </summary>
|
|
public static LocalCacheData? TryGetCachedState(string productSlug)
|
|
{
|
|
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
|
return StateStore.Load(productSlug, hwInfo.HardwareId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prueft erneut mit dem zuletzt zwischengespeicherten Schluessel.
|
|
/// Liefert not_found, wenn noch nie erfolgreich aktiviert wurde.
|
|
/// </summary>
|
|
public async Task<LicenseValidationResult> RevalidateAsync(
|
|
string productSlug,
|
|
string serverBaseUrl,
|
|
string? appVersion = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
string? key = TryGetCachedKey(productSlug);
|
|
|
|
if (key == null)
|
|
{
|
|
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
|
return new LicenseValidationResult
|
|
{
|
|
IsValid = false,
|
|
Status = "not_found",
|
|
Message = "Kein zwischengespeicherter Lizenzschluessel vorhanden.",
|
|
HardwareId = hwInfo.HardwareId,
|
|
IsTransient = false
|
|
};
|
|
}
|
|
|
|
return await ValidateAsync(productSlug, key, serverBaseUrl, appVersion, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<bool> DeactivateAsync(
|
|
string productSlug,
|
|
string licenseKey,
|
|
string serverBaseUrl,
|
|
string authToken = "",
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var hwInfo = HardwareId.GetHardwareId(productSlug);
|
|
var payload = new
|
|
{
|
|
product = productSlug,
|
|
license_key = licenseKey,
|
|
hardware_id = hwInfo.HardwareId,
|
|
nonce = Guid.NewGuid().ToString("N")
|
|
};
|
|
|
|
string jsonStr = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(jsonStr, Encoding.UTF8, "application/json");
|
|
string endpoint = $"{serverBaseUrl.TrimEnd('/')}/api/license/v1/deactivate";
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
|
{
|
|
Content = content
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(authToken))
|
|
{
|
|
// Nur noch der Standardweg. Der zusaetzliche X-Watchdog-Key war ein
|
|
// Ueberbleibsel des alten Servers und irrefuehrend benannt: hier
|
|
// gehoert der shared_key hin, kein Watchdog-Token. Erschwerend
|
|
// prueft der Server X-Watchdog-Key VOR Authorization - ein dort
|
|
// versehentlich eingetragenes Watchdog-Token haette das richtige
|
|
// Bearer-Token stillschweigend verdraengt.
|
|
request.Headers.Add("Authorization", $"Bearer {authToken}");
|
|
}
|
|
|
|
try
|
|
{
|
|
using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
return response.IsSuccessStatusCode;
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string ResolveAppVersion(string? explicitVersion)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(explicitVersion))
|
|
{
|
|
return explicitVersion!.Trim();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(DefaultAppVersion))
|
|
{
|
|
return DefaultAppVersion!.Trim();
|
|
}
|
|
|
|
// Vorher stand hier fest "1.0.0". In der Aktivierungsliste des
|
|
// Deploymentcenters trug damit jede Installation dieselbe Version,
|
|
// obwohl die Spalte app_version dafuer vorgesehen ist.
|
|
try
|
|
{
|
|
var assembly = Assembly.GetEntryAssembly();
|
|
if (assembly != null)
|
|
{
|
|
string? informational = assembly
|
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
|
|
|
if (!string.IsNullOrWhiteSpace(informational))
|
|
{
|
|
// Das SDK haengt bei manchen Buildeinstellungen "+<commit>" an.
|
|
int plus = informational!.IndexOf('+');
|
|
return plus > 0 ? informational.Substring(0, plus) : informational;
|
|
}
|
|
|
|
var version = assembly.GetName().Version;
|
|
if (version != null)
|
|
{
|
|
return version.ToString();
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Version zu ermitteln darf eine Lizenzpruefung nie scheitern lassen.
|
|
}
|
|
|
|
return "unknown";
|
|
}
|
|
}
|