EllieHub/EllieHub/Features/Home/Services/AppResolver.cs

265 lines
No EOL
11 KiB
C#

using Toastie.Utilities;
using Microsoft.Extensions.Caching.Memory;
using EllieHub.Features.Home.Models.Api.Toastielab;
using EllieHub.Features.Home.Services.Abstractions;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Text.Json;
namespace EllieHub.Features.Home.Services;
/// <summary>
/// Defines a service that updates this application.
/// </summary>
public sealed class AppResolver : IAppResolver
{
private const string _cachedCurrentVersionKey = "currentVersion:EllieHub";
private const string _toastielabReleasesEndpointUrl = "https://toastielab.dev/api/v1/repos/EllieBotDevs/EllieHub/releases/latest";
private const string _toastielabReleasesRepoUrl = "https://toastielab.dev/EllieBotDevs/EllieHub/releases/latest";
private static readonly string _tempDirectory = Path.GetTempPath();
private static readonly string _downloadedFileName = GetDownloadFileName();
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
/// <inheritdoc/>
public string DependencyName { get; } = "EllieHub";
/// <inheritdoc/>
public string OldFileSuffix { get; } = "_old";
/// <inheritdoc/>
public string FileName { get; }
/// <inheritdoc/>
public string BinaryUri { get; }
/// <summary>
/// Creates a service that updates this application.
/// </summary>
/// <param name="httpClientFactory">The Http client factory.</param>
/// <param name="memoryCache">The memory cache.</param>
public AppResolver(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache)
{
_httpClientFactory = httpClientFactory;
_memoryCache = memoryCache;
FileName = OperatingSystem.IsWindows() ? "EllieHub.exe" : "EllieHub";
BinaryUri = Path.Join(AppContext.BaseDirectory, FileName);
}
/// <inheritdoc/>
public ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default)
=> ValueTask.FromResult<string?>(AppStatics.AppVersion);
/// <inheritdoc/>
public void LaunchNewVersion()
=> ToastieUtilities.StartProcess(BinaryUri);
/// <returns>
/// <see langword="true"/> if the updater can be updated,
/// <see langword="false"/> if the updater is up-to-date,
/// <see langword="null"/> if the updater cannot update itself.
/// </returns>
/// <inheritdoc/>
public async ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default)
{
if (!ToastieUtilities.HasWritePermissionAt(AppContext.BaseDirectory))
return null;
var currentVersion = await GetCurrentVersionAsync(cToken);
if (currentVersion is null)
return null;
var latestVersion = await GetLatestVersionAsync(cToken);
if (Version.Parse(latestVersion) <= Version.Parse(currentVersion))
return false;
var http = _httpClientFactory.CreateClient();
return await http.IsUrlValidAsync(
await GetDownloadUrlAsync(latestVersion, cToken),
cToken
);
}
/// <inheritdoc/>
public bool RemoveOldFiles()
{
var result = false;
foreach (var file in Directory.GetFiles(AppContext.BaseDirectory).Where(x => x.EndsWith(OldFileSuffix, StringComparison.Ordinal)))
result |= ToastieUtilities.TryDeleteFile(file);
return result;
}
/// <inheritdoc/>
public async ValueTask<string> GetLatestVersionAsync(CancellationToken cToken = default)
{
try
{
return (await GetLatestVersionFromApiAsync(cToken)).Tag;
}
catch (InvalidOperationException)
{
return await GetLatestVersionFromUrlAsync(cToken);
}
}
/// <inheritdoc/>
public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
{
var currentVersion = await GetCurrentVersionAsync(cToken);
var latestVersion = await GetLatestVersionAsync(cToken);
if (currentVersion is not null && Version.Parse(latestVersion) <= Version.Parse(currentVersion))
return (currentVersion, null);
var http = _httpClientFactory.CreateClient(); // Do not initialize a ToastielabClient here, it returns 302 with no data
var appTempLocation = Path.Join(_tempDirectory, _downloadedFileName[.._downloadedFileName.LastIndexOf('.')]);
var zipTempLocation = Path.Join(_tempDirectory, _downloadedFileName);
try
{
await using var downloadStream = await http.GetStreamAsync(
await GetDownloadUrlAsync(latestVersion, cToken),
cToken
);
// Save the zip file
await using (var fileStream = new FileStream(zipTempLocation, FileMode.Create))
await downloadStream.CopyToAsync(fileStream, cToken);
// Extract the zip file
await Task.Run(() => ZipFile.ExtractToDirectory(zipTempLocation, _tempDirectory), cToken);
// Move the new binary and its dependencies
var newFilesUris = Directory.EnumerateFiles(appTempLocation);
foreach (var newFileUri in newFilesUris)
{
var destinationUri = Path.Join(AppContext.BaseDirectory, newFileUri[(newFileUri.LastIndexOf(Path.DirectorySeparatorChar) + 1)..]);
// Rename the original file from "file" to "file_old".
if (File.Exists(destinationUri))
File.Move(destinationUri, destinationUri + OldFileSuffix, true); // This executes fine
// Move the new file to the application's directory.
// ...
// This is a workaround for a really weird bug with Unix applications published as single-file.
// The moving operation works, but invoking any process from the shell results in:
// FileNotFoundException: Could not load file or assembly 'System.IO.Pipes, Version=9.0.0.0 [...]
if (Environment.OSVersion.Platform is not PlatformID.Unix)
File.Move(newFileUri, destinationUri, true);
else
{
using var moveProcess = ToastieUtilities.StartProcess("mv", [newFileUri, destinationUri]);
await moveProcess.WaitForExitAsync(cToken);
}
}
// Mark the new binary file as executable.c
if (Environment.OSVersion.Platform is PlatformID.Unix)
{
using var chmod = ToastieUtilities.StartProcess("chmod", ["+x", BinaryUri]);
await chmod.WaitForExitAsync(cToken);
}
return (currentVersion, latestVersion);
}
finally
{
// Cleanup
ToastieUtilities.TryDeleteFile(zipTempLocation);
ToastieUtilities.TryDeleteDirectory(appTempLocation);
}
}
/// <summary>
/// Gets the name of the file to be downloaded.
/// </summary>
/// <returns>The name of the file to be downloaded.</returns>
/// <exception cref="NotSupportedException">Occurs when this method is used in an unsupported system.</exception>
private static string GetDownloadFileName()
{
return RuntimeInformation.OSArchitecture switch
{
// Windows
Architecture.X64 when OperatingSystem.IsWindows() => "EllieHub_win-x64.zip",
Architecture.Arm64 when OperatingSystem.IsWindows() => "EllieHub_win-arm64.zip",
// Linux
Architecture.X64 when OperatingSystem.IsLinux() => "EllieHub_linux-x64.zip",
Architecture.Arm64 when OperatingSystem.IsLinux() => "EllieHub_linux-arm64.zip",
// MacOS
Architecture.X64 when OperatingSystem.IsMacOS() => "EllieHub_osx-x64.zip",
Architecture.Arm64 when OperatingSystem.IsMacOS() => "EllieHub_osx-arm64.zip",
_ => throw new NotSupportedException($"Architecture of type {RuntimeInformation.OSArchitecture} is not supported by EllieHub on this OS.")
};
}
/// <summary>
/// Gets the download url to the latest bot release.
/// </summary>
/// <param name="latestVersion">The latest version of the bot.</param>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The url to the latest bot release.</returns>
private async ValueTask<string> GetDownloadUrlAsync(string latestVersion, CancellationToken cToken = default)
{
try
{
// The first release is the most recent one.
return (await GetLatestVersionFromApiAsync(cToken)).Assets
.First(x => x.Name.Equals(_downloadedFileName, StringComparison.Ordinal))
.Url;
}
catch (InvalidOperationException)
{
return $"https://toastielab.dev/EllieBotDevs/EllieHub/releases/download/{latestVersion}/{_downloadedFileName}";
}
}
/// <summary>
/// Gets the latest bot version from the Toastielab latest release URL.
/// </summary>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The latest version of the bot.</returns>
/// <exception cref="InvalidOperationException">Occurs when parsing of the response fails.</exception>
private async ValueTask<string> GetLatestVersionFromUrlAsync(CancellationToken cToken = default)
{
var http = _httpClientFactory.CreateClient(AppConstants.ToastielabClient);
var response = await http.GetAsync(_toastielabReleasesRepoUrl, cToken);
var lastSlashIndex = response.Headers.Location?.OriginalString.LastIndexOf('/')
?? throw new InvalidOperationException("Failed to get the latest EllieHub version.");
return response.Headers.Location.OriginalString[(lastSlashIndex + 1)..];
}
/// <summary>
/// Gets the latest bot version from the Toastielab API.
/// </summary>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The latest version of the bot.</returns>
/// <exception cref="InvalidOperationException">Occurs when the API call fails.</exception>
private async ValueTask<ToastielabRelease> GetLatestVersionFromApiAsync(CancellationToken cToken = default)
{
if (_memoryCache.TryGetValue(_cachedCurrentVersionKey, out var cachedObject) && cachedObject is ToastielabRelease cachedResponse)
return cachedResponse;
var http = _httpClientFactory.CreateClient(AppConstants.ToastielabClient);
var httpResponse = await http.GetAsync(_toastielabReleasesEndpointUrl, cToken);
if (!httpResponse.IsSuccessStatusCode)
throw new InvalidOperationException("The call to the Toastielab API failed.");
var response = JsonSerializer.Deserialize<ToastielabRelease>(await httpResponse.Content.ReadAsStringAsync(cToken))
?? throw new InvalidOperationException("Failed deserializing Toastielab's response.");
_memoryCache.Set(_cachedCurrentVersionKey, response, TimeSpan.FromMinutes(1));
return response;
}
}