using System; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using StreamPlayer.Desktop.Models; namespace StreamPlayer.Desktop.Services; public sealed class UpdateService { private static readonly HttpClient HttpClient = new(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }) { Timeout = TimeSpan.FromSeconds(20) }; public async Task CheckForUpdatesAsync(CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, AppVersion.LatestReleaseApi); using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); var root = document.RootElement; string tagName = root.GetPropertyOrDefault("tag_name"); string versionName = DeriveVersionName(tagName, root.GetPropertyOrDefault("name")); int versionCode = ParseVersionCode(versionName); string releaseNotes = root.GetPropertyOrDefault("body"); string releasePageUrl = root.GetPropertyOrDefault("html_url"); string downloadUrl = string.Empty; string downloadFileName = string.Empty; long sizeBytes = 0; int downloadCount = 0; JsonElement assetsElement = default; bool hasAssets = root.TryGetProperty("assets", out assetsElement) && assetsElement.ValueKind == JsonValueKind.Array; if (hasAssets && (TryFindAsset(assetsElement, IsApkAsset, out var apkAsset) || TryGetFirstAsset(assetsElement, out apkAsset))) { downloadUrl = apkAsset.GetPropertyOrDefault("browser_download_url"); downloadFileName = apkAsset.GetPropertyOrDefault("name"); long.TryParse(apkAsset.GetPropertyOrDefault("size"), out sizeBytes); int.TryParse(apkAsset.GetPropertyOrDefault("download_count"), out downloadCount); } var manifest = hasAssets ? await TryFetchManifestAsync(assetsElement, cancellationToken).ConfigureAwait(false) : null; if (manifest is not null) { versionCode = manifest.Value.GetPropertyOrDefaultInt("versionCode", versionCode); var manifestVersionName = manifest.Value.GetPropertyOrDefault("versionName"); if (!string.IsNullOrWhiteSpace(manifestVersionName)) { versionName = manifestVersionName; } int minSupported = manifest.Value.GetPropertyOrDefaultInt("minSupportedVersionCode", 0); bool forceUpdate = manifest.Value.GetPropertyOrDefaultBool("forceUpdate", false); string manifestUrl = manifest.Value.GetPropertyOrDefault("downloadUrl"); if (!string.IsNullOrWhiteSpace(manifestUrl)) { downloadUrl = manifestUrl; } string manifestFileName = manifest.Value.GetPropertyOrDefault("fileName"); if (!string.IsNullOrWhiteSpace(manifestFileName)) { downloadFileName = manifestFileName; } long manifestSize = manifest.Value.GetPropertyOrDefaultLong("sizeBytes", sizeBytes); if (manifestSize > 0) { sizeBytes = manifestSize; } string manifestNotes = manifest.Value.GetPropertyOrDefault("notes"); if (!string.IsNullOrWhiteSpace(manifestNotes) && string.IsNullOrWhiteSpace(releaseNotes)) { releaseNotes = manifestNotes; } return BuildInfo(versionCode, versionName, releaseNotes, downloadUrl, downloadFileName, sizeBytes, downloadCount, releasePageUrl, minSupported, forceUpdate); } return BuildInfo(versionCode, versionName, releaseNotes, downloadUrl, downloadFileName, sizeBytes, downloadCount, releasePageUrl, 0, false); } private static UpdateInfo? BuildInfo( int versionCode, string versionName, string releaseNotes, string downloadUrl, string downloadFileName, long sizeBytes, int downloadCount, string releasePageUrl, int minSupported, bool forceUpdate) { if (string.IsNullOrWhiteSpace(downloadUrl)) { return null; } return new UpdateInfo( versionCode, versionName, releaseNotes, downloadUrl, downloadFileName, sizeBytes, downloadCount, releasePageUrl, minSupported, forceUpdate); } private static async Task TryFetchManifestAsync(JsonElement assets, CancellationToken cancellationToken) { foreach (var asset in assets.EnumerateArray()) { string name = asset.GetPropertyOrDefault("name").ToLowerInvariant(); if (!name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { continue; } if (!name.Contains("update", StringComparison.OrdinalIgnoreCase) && !name.Contains("manifest", StringComparison.OrdinalIgnoreCase)) { continue; } string url = asset.GetPropertyOrDefault("browser_download_url"); if (string.IsNullOrWhiteSpace(url)) { continue; } try { using var manifestResponse = await HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); manifestResponse.EnsureSuccessStatusCode(); await using var stream = await manifestResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); return document.RootElement.Clone(); } catch { // Try next manifest candidate. } } return null; } private static bool TryFindAsset(JsonElement assets, Func predicate, out JsonElement asset) { if (assets.ValueKind != JsonValueKind.Array) { asset = default; return false; } foreach (var candidate in assets.EnumerateArray()) { if (predicate(candidate)) { asset = candidate; return true; } } asset = default; return false; } private static bool IsApkAsset(JsonElement asset) { string name = asset.GetPropertyOrDefault("name").ToLowerInvariant(); return name.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); } private static bool TryGetFirstAsset(JsonElement assets, out JsonElement asset) { if (assets.ValueKind == JsonValueKind.Array) { using var enumerator = assets.EnumerateArray().GetEnumerator(); if (enumerator.MoveNext()) { asset = enumerator.Current; return true; } } asset = default; return false; } private static string DeriveVersionName(string tagName, string fallback) { string baseName = string.IsNullOrWhiteSpace(tagName) ? fallback : tagName; if (string.IsNullOrWhiteSpace(baseName)) { return string.Empty; } return Regex.Replace(baseName, @"^[Vv]", string.Empty).Trim(); } private static int ParseVersionCode(string versionName) { if (string.IsNullOrWhiteSpace(versionName)) { return -1; } var parts = versionName.Split('.', StringSplitOptions.RemoveEmptyEntries); int major = ParsePart(parts, 0); int minor = ParsePart(parts, 1); int patch = ParsePart(parts, 2); return major * 10000 + minor * 100 + patch; } private static int ParsePart(IReadOnlyList parts, int index) { if (index >= parts.Count) { return 0; } if (int.TryParse(Regex.Replace(parts[index], @"[^\d]", string.Empty), out int value)) { return value; } return 0; } }