using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Reflection; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using KronaStaffTerminal.Models; namespace KronaStaffTerminal.Services; public sealed record AvailableUpdate( Version Version, string DisplayVersion, string ReleaseName, string ReleaseNotes, Uri ReleasePage, Uri InstallerUrl, Uri ChecksumUrl, string InstallerFileName); public interface IUpdateSource { Task FindUpdateAsync(TerminalSettings settings, CancellationToken cancellationToken); Task DownloadAndVerifyAsync( AvailableUpdate update, TerminalSettings settings, IProgress? progress, CancellationToken cancellationToken); } public sealed class ForgejoUpdateSource : IUpdateSource { private const string InstallerPrefix = "KronaStaffTerminal-Setup-"; private static readonly HttpClient HttpClient = CreateHttpClient(); public async Task FindUpdateAsync( TerminalSettings settings, CancellationToken cancellationToken) { if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) || feedUri.Scheme != Uri.UriSchemeHttps) { throw new InvalidOperationException( "Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo."); } using var request = CreateRequest(HttpMethod.Get, feedUri, settings.UpdateAccessToken); using var response = await HttpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound) { throw new InvalidOperationException( "Нет доступа к обновлениям. Укажите токен чтения приватного репозитория в настройках."); } response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var release = await JsonSerializer.DeserializeAsync( stream, cancellationToken: cancellationToken) ?? throw new InvalidDataException("Forgejo вернул пустое описание релиза."); if (!TryParseVersion(release.TagName, out var releaseVersion) || releaseVersion <= CurrentVersion) { return null; } var installer = release.Assets.FirstOrDefault(asset => asset.Name.StartsWith(InstallerPrefix, StringComparison.OrdinalIgnoreCase) && asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); var checksum = release.Assets.FirstOrDefault(asset => string.Equals(asset.Name, installer?.Name + ".sha256", StringComparison.OrdinalIgnoreCase)); if (installer is null || checksum is null || !Uri.TryCreate(installer.BrowserDownloadUrl, UriKind.Absolute, out var installerUri) || !Uri.TryCreate(checksum.BrowserDownloadUrl, UriKind.Absolute, out var checksumUri) || !Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releasePage)) { throw new InvalidDataException( "В релизе нет установщика Windows или файла контрольной суммы."); } return new AvailableUpdate( releaseVersion, releaseVersion.ToString(3), string.IsNullOrWhiteSpace(release.Name) ? $"Krona Staff {releaseVersion.ToString(3)}" : release.Name, release.Body ?? "", releasePage, installerUri, checksumUri, installer.Name); } public async Task DownloadAndVerifyAsync( AvailableUpdate update, TerminalSettings settings, IProgress? progress, CancellationToken cancellationToken) { var updateDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Krona", "StaffTerminal", "updates", update.DisplayVersion); Directory.CreateDirectory(updateDirectory); var installerPath = Path.Combine( updateDirectory, Path.GetFileName(update.InstallerFileName)); var temporaryPath = installerPath + ".download"; using var checksumRequest = CreateRequest( HttpMethod.Get, update.ChecksumUrl, settings.UpdateAccessToken); using var checksumResponse = await HttpClient.SendAsync( checksumRequest, cancellationToken); checksumResponse.EnsureSuccessStatusCode(); var checksumText = await checksumResponse.Content.ReadAsStringAsync(cancellationToken); var expectedHash = checksumText .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(); if (expectedHash is null || expectedHash.Length != 64 || expectedHash.Any(character => !Uri.IsHexDigit(character))) { throw new InvalidDataException( "Файл контрольной суммы обновления повреждён."); } using var installerRequest = CreateRequest( HttpMethod.Get, update.InstallerUrl, settings.UpdateAccessToken); using var installerResponse = await HttpClient.SendAsync( installerRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); installerResponse.EnsureSuccessStatusCode(); var totalBytes = installerResponse.Content.Headers.ContentLength; await using (var source = await installerResponse.Content.ReadAsStreamAsync(cancellationToken)) await using (var destination = new FileStream( temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous)) { var buffer = new byte[81920]; long received = 0; int count; while ((count = await source.ReadAsync(buffer, cancellationToken)) > 0) { await destination.WriteAsync(buffer.AsMemory(0, count), cancellationToken); received += count; if (totalBytes is > 0) { progress?.Report((double)received / totalBytes.Value); } } await destination.FlushAsync(cancellationToken); } await using var downloaded = File.OpenRead(temporaryPath); var actualHash = Convert.ToHexString( await SHA256.HashDataAsync(downloaded, cancellationToken)); if (!CryptographicOperations.FixedTimeEquals( Convert.FromHexString(expectedHash), Convert.FromHexString(actualHash))) { File.Delete(temporaryPath); throw new InvalidDataException( "Контрольная сумма обновления не совпала. Установка отменена."); } File.Move(temporaryPath, installerPath, true); return installerPath; } public static Version CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0); public static void LaunchInstaller(string installerPath) => Process.Start(new ProcessStartInfo { FileName = installerPath, UseShellExecute = true }); private static HttpRequestMessage CreateRequest( HttpMethod method, Uri uri, string token) { var request = new HttpRequestMessage(method, uri); if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("token", token.Trim()); } return request; } private static HttpClient CreateHttpClient() { var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; client.DefaultRequestHeaders.UserAgent.ParseAdd("KronaStaffTerminal/0.2"); return client; } private static bool TryParseVersion(string? value, out Version version) { var normalized = (value ?? "") .Trim() .TrimStart('v', 'V') .Split('-', '+')[0]; if (Version.TryParse(normalized, out var parsed)) { version = parsed; return true; } version = new Version(0, 0, 0); return false; } private sealed class ForgejoRelease { [JsonPropertyName("tag_name")] public string TagName { get; set; } = ""; [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("body")] public string? Body { get; set; } [JsonPropertyName("html_url")] public string HtmlUrl { get; set; } = ""; [JsonPropertyName("assets")] public List Assets { get; set; } = []; } private sealed class ForgejoAsset { [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("browser_download_url")] public string BrowserDownloadUrl { get; set; } = ""; } }