261 lines
9.7 KiB
C#
261 lines
9.7 KiB
C#
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<AvailableUpdate?> FindUpdateAsync(TerminalSettings settings, CancellationToken cancellationToken);
|
||
Task<string> DownloadAndVerifyAsync(
|
||
AvailableUpdate update,
|
||
TerminalSettings settings,
|
||
IProgress<double>? progress,
|
||
CancellationToken cancellationToken);
|
||
}
|
||
|
||
public sealed class ForgejoUpdateSource : IUpdateSource
|
||
{
|
||
private const string InstallerPrefix = "KronaStaffTerminal-Setup-";
|
||
private static readonly HttpClient HttpClient = CreateHttpClient();
|
||
|
||
public async Task<AvailableUpdate?> 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<ForgejoRelease>(
|
||
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<string> DownloadAndVerifyAsync(
|
||
AvailableUpdate update,
|
||
TerminalSettings settings,
|
||
IProgress<double>? 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<ForgejoAsset> Assets { get; set; } = [];
|
||
}
|
||
|
||
private sealed class ForgejoAsset
|
||
{
|
||
[JsonPropertyName("name")]
|
||
public string Name { get; set; } = "";
|
||
|
||
[JsonPropertyName("browser_download_url")]
|
||
public string BrowserDownloadUrl { get; set; } = "";
|
||
}
|
||
}
|