using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Deploymentcenter.Client.Models; namespace Deploymentcenter.Client { public class UpdateCheckResult { public bool UpdateAvailable { get; set; } public bool IsCritical { get; set; } public VersionInfo? LatestRelease { get; set; } public ReleaseManifest? FullManifest { get; set; } public string Message { get; set; } = string.Empty; public Exception? Error { get; set; } } public class IntegrityCheckResult { public bool IsValid { get; set; } = true; public List MissingFiles { get; } = new List(); public List CorruptedFiles { get; } = new List(); public int TotalCheckedFiles { get; set; } } public class UpdateClient { private static readonly HttpClient SharedHttpClient = new HttpClient(); private readonly HttpClient _httpClient; public UpdateClient(HttpClient? httpClient = null) { _httpClient = httpClient ?? SharedHttpClient; } /// /// Checks for update availability against LEMP static latest.json or Deploymentcenter API. /// public async Task CheckForUpdateAsync( string baseUrl, string projectId, string currentVersion, string channel = "prod", CancellationToken cancellationToken = default) { var result = new UpdateCheckResult(); try { string cleanBaseUrl = baseUrl.TrimEnd('/'); // Primary check: LEMP static channel latest.json // Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json"; // Zuerst die statische latest.json, danach die API. // // Der Rueckfall auf die API war zuvor unerreichbar: er lag in // einem catch, aber GetAsync wirft bei einem 404 keine Exception, // sondern liefert eine Antwort mit Statuscode. Fehlte die // latest.json, brach die Pruefung mit "HTTP Error NotFound" ab, // statt die API zu befragen. HttpResponseMessage? response = null; try { response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false); } catch { response = null; } if (response == null || !response.IsSuccessStatusCode) { response?.Dispose(); string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check" + $"?product={Uri.EscapeDataString(projectId)}" + $"&version={Uri.EscapeDataString(currentVersion)}" + $"&channel={Uri.EscapeDataString(channel)}"; response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false); } if (!response.IsSuccessStatusCode) { result.Message = $"Update-Pruefung fehlgeschlagen: HTTP {(int)response.StatusCode}"; return result; } string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; // Handle static latest.json format if (root.TryGetProperty("latest", out var latestProp) && latestProp.ValueKind == JsonValueKind.Object) { var manifest = JsonSerializer.Deserialize(json); if (manifest?.Latest != null) { result.FullManifest = manifest; result.LatestRelease = manifest.Latest; if (IsVersionNewer(currentVersion, manifest.Latest.Version)) { result.UpdateAvailable = true; result.IsCritical = manifest.Latest.IsCritical; result.Message = $"New release v{manifest.Latest.Version} available."; } else { result.Message = "Application is up to date."; } } } // Handle API response format else if (root.TryGetProperty("update_available", out var availProp)) { bool available = availProp.GetBoolean(); result.UpdateAvailable = available; if (root.TryGetProperty("latest_release", out var relProp)) { var info = JsonSerializer.Deserialize(relProp.GetRawText()); result.LatestRelease = info; result.IsCritical = info?.IsCritical ?? false; } result.Message = available ? "Update available." : "Application is up to date."; } } catch (Exception ex) { result.Error = ex; result.Message = $"Update check failed: {ex.Message}"; } return result; } /// /// Validates local application integrity against manifest.json. /// public static IntegrityCheckResult VerifyIntegrity(string localAppDir, PackageManifest manifest) { var result = new IntegrityCheckResult(); if (manifest == null || manifest.Files == null || manifest.Files.Count == 0) { return result; } foreach (var entry in manifest.Files) { result.TotalCheckedFiles++; string fullPath = Path.Combine(localAppDir, entry.Path.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(fullPath)) { result.IsValid = false; result.MissingFiles.Add(entry.Path); continue; } if (!string.IsNullOrEmpty(entry.Sha256)) { string computedHash = ComputeSha256(fullPath); if (!string.Equals(computedHash, entry.Sha256, StringComparison.OrdinalIgnoreCase)) { result.IsValid = false; result.CorruptedFiles.Add(entry.Path); } } } return result; } /// /// Launches UpdateAgent process with appropriate parameters and optionally exits current application. /// public static bool LaunchUpdateAgent( string agentPath, string projectId, string channel = "prod", string action = "update", string version = "latest", string? targetDir = null, bool exitCurrentApp = true) { if (!File.Exists(agentPath)) { return false; } targetDir ??= AppDomain.CurrentDomain.BaseDirectory; var args = new StringBuilder(); args.Append($"--project \"{projectId}\" "); args.Append($"--channel \"{channel}\" "); args.Append($"--action \"{action}\" "); args.Append($"--version \"{version}\" "); args.Append($"--target-dir \"{targetDir}\""); var startInfo = new ProcessStartInfo { FileName = agentPath, Arguments = args.ToString(), UseShellExecute = true }; Process.Start(startInfo); if (exitCurrentApp) { Environment.Exit(0); } return true; } public static string ComputeSha256(string filePath) { using var sha256 = SHA256.Create(); using var stream = File.OpenRead(filePath); byte[] hash = sha256.ComputeHash(stream); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } /// /// Prueft, ob neuer ist als . /// /// Die vorherige Fassung entfernte zwar die Vorabkennung, nicht aber ein /// fuehrendes "v". Damit scheiterte Version.TryParse bei Angaben wie /// "v1.4.2" und es wurde auf einen alphabetischen Vergleich /// zurueckgefallen - dort gilt "v1.9.0" faelschlich als neuer als /// "v1.10.0". Das entspricht dem Fehler, der serverseitig in der /// SQL-Abfrage steckte. /// public static bool IsVersionNewer(string currentVer, string remoteVer) { if (string.IsNullOrWhiteSpace(remoteVer)) return false; if (string.IsNullOrWhiteSpace(currentVer)) return true; return CompareVersions(remoteVer, currentVer) > 0; } /// /// Vergleicht zwei Versionsangaben nach semantischer Ordnung. /// Rueckgabe: negativ wenn a < b, 0 bei Gleichstand, positiv wenn a > b. /// public static int CompareVersions(string a, string b) { var (coreA, preA) = ParseVersion(a); var (coreB, preB) = ParseVersion(b); int length = Math.Max(coreA.Count, coreB.Count); for (int i = 0; i < length; i++) { int partA = i < coreA.Count ? coreA[i] : 0; int partB = i < coreB.Count ? coreB[i] : 0; if (partA != partB) { return partA.CompareTo(partB); } } // Eine Version ohne Vorabkennung rangiert ueber derselben mit: // 1.0.0 ist neuer als 1.0.0-rc.1 bool emptyA = preA.Count == 0; bool emptyB = preB.Count == 0; if (emptyA && emptyB) return 0; if (emptyA) return 1; if (emptyB) return -1; int preLength = Math.Max(preA.Count, preB.Count); for (int i = 0; i < preLength; i++) { if (i >= preA.Count) return -1; if (i >= preB.Count) return 1; bool numericA = int.TryParse(preA[i], out int numA); bool numericB = int.TryParse(preB[i], out int numB); if (numericA && numericB) { if (numA != numB) return numA.CompareTo(numB); continue; } // Rein numerische Bestandteile rangieren unter alphanumerischen. if (numericA != numericB) return numericA ? -1 : 1; int cmp = string.CompareOrdinal(preA[i], preB[i]); if (cmp != 0) return cmp > 0 ? 1 : -1; } return 0; } private static (List Core, List Prerelease) ParseVersion(string version) { string value = (version ?? string.Empty).Trim().TrimStart('v', 'V'); // Build-Metadaten sind fuer die Rangfolge ohne Bedeutung. int plus = value.IndexOf('+'); if (plus >= 0) value = value.Substring(0, plus); var prerelease = new List(); int dash = value.IndexOf('-'); if (dash >= 0) { string preString = value.Substring(dash + 1); value = value.Substring(0, dash); if (preString.Length > 0) { prerelease.AddRange(preString.Split('.')); } } var core = new List(); foreach (string part in value.Split('.')) { string digits = new string(part.Where(char.IsDigit).ToArray()); core.Add(digits.Length > 0 ? int.Parse(digits) : 0); } if (core.Count == 0) core.Add(0); return (core, prerelease); } } }