diff --git a/src/Artemis.UI.Shared/Providers/IUpdateProvider.cs b/src/Artemis.UI.Shared/Providers/IUpdateProvider.cs deleted file mode 100644 index 0c33b135f..000000000 --- a/src/Artemis.UI.Shared/Providers/IUpdateProvider.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Threading.Tasks; - -namespace Artemis.UI.Shared.Providers; - -/// -/// Represents a provider for custom cursors. -/// -public interface IUpdateProvider -{ - /// - /// Asynchronously checks whether an update is available. - /// - /// The channel to use when checking updates (i.e. master or development) - /// A task returning if an update is available; otherwise . - Task CheckForUpdate(string channel); - - /// - /// Applies any available updates. - /// - /// The channel to use when checking updates (i.e. master or development) - /// Whether or not to update silently. - Task ApplyUpdate(string channel, bool silent); - - /// - /// Offer to install the update to the user. - /// - /// The channel to use when checking updates (i.e. master or development) - /// A boolean indicating whether the main window is open. - /// A task returning if the user chose to update; otherwise . - Task OfferUpdate(string channel, bool windowOpen); -} \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Providers/UpdateProvider.cs b/src/Artemis.UI.Windows/Providers/UpdateProvider.cs deleted file mode 100644 index d3d20081e..000000000 --- a/src/Artemis.UI.Windows/Providers/UpdateProvider.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using Artemis.Core; -using Artemis.UI.Exceptions; -using Artemis.UI.Shared.Providers; -using Artemis.UI.Shared.Services; -using Artemis.UI.Shared.Services.MainWindow; -using Artemis.UI.Windows.Models; -using Artemis.UI.Windows.Screens.Update; -using Avalonia.Threading; -using Flurl; -using Flurl.Http; -using Microsoft.Toolkit.Uwp.Notifications; -using Serilog; -using File = System.IO.File; - -namespace Artemis.UI.Windows.Providers; - -public class UpdateProvider : IUpdateProvider, IDisposable -{ - private const string API_URL = "https://dev.azure.com/artemis-rgb/Artemis/_apis/"; - private const string INSTALLER_URL = "https://builds.artemis-rgb.com/binaries/Artemis.Installer.exe"; - - private readonly ILogger _logger; - private readonly IMainWindowService _mainWindowService; - private readonly IWindowService _windowService; - - public UpdateProvider(ILogger logger, IWindowService windowService, IMainWindowService mainWindowService) - { - _logger = logger; - _windowService = windowService; - _mainWindowService = mainWindowService; - - ToastNotificationManagerCompat.OnActivated += ToastNotificationManagerCompatOnOnActivated; - } - - public async Task GetBuildInfo(int buildDefinition, string? buildNumber = null) - { - Url request = API_URL.AppendPathSegments("build", "builds") - .SetQueryParam("definitions", buildDefinition) - .SetQueryParam("resultFilter", "succeeded") - .SetQueryParam("branchName", "refs/heads/master") - .SetQueryParam("$top", 1) - .SetQueryParam("api-version", "6.1-preview.6"); - - if (buildNumber != null) - request = request.SetQueryParam("buildNumber", buildNumber); - - try - { - DevOpsBuilds result = await request.GetJsonAsync(); - try - { - return result.Builds.FirstOrDefault(); - } - catch (Exception e) - { - _logger.Warning(e, "GetBuildInfo: Failed to retrieve build info JSON"); - throw; - } - } - catch (FlurlHttpException e) - { - _logger.Warning("GetBuildInfo: Getting build info, request returned {StatusCode}", e.StatusCode); - throw; - } - } - - public async Task GetBuildDifferences(DevOpsBuild a, DevOpsBuild b) - { - return await "https://api.github.com" - .AppendPathSegments("repos", "Artemis-RGB", "Artemis", "compare") - .AppendPathSegment(a.SourceVersion + "..." + b.SourceVersion) - .WithHeader("User-Agent", "Artemis 2") - .WithHeader("Accept", "application/vnd.github.v3+json") - .GetJsonAsync(); - } - - private async void ToastNotificationManagerCompatOnOnActivated(ToastNotificationActivatedEventArgsCompat e) - { - ToastArguments args = ToastArguments.Parse(e.Argument); - string channel = args.Get("channel"); - string action = "view-changes"; - if (args.Contains("action")) - action = args.Get("action"); - - if (action == "install") - await RunInstaller(channel, true); - else if (action == "view-changes") - await Dispatcher.UIThread.InvokeAsync(async () => - { - _mainWindowService.OpenMainWindow(); - await OfferUpdate(channel, true); - }); - } - - private async Task RunInstaller(string channel, bool silent) - { - _logger.Information("ApplyUpdate: Applying update"); - - // Ensure the installer is up-to-date, get installer build info - DevOpsBuild? buildInfo = await GetBuildInfo(6); - string installerPath = Path.Combine(Constants.DataFolder, "installer", "Artemis.Installer.exe"); - - // Always update installer if it is missing ^^ - if (!File.Exists(installerPath)) - { - await UpdateInstaller(); - } - // Compare the creation date of the installer with the build date and update if needed - else - { - if (buildInfo != null && File.GetLastWriteTime(installerPath) < buildInfo.FinishTime) - await UpdateInstaller(); - } - - _logger.Information("ApplyUpdate: Running installer at {InstallerPath}", installerPath); - - try - { - Process.Start(new ProcessStartInfo(installerPath, "-autoupdate") - { - UseShellExecute = true, - Verb = "runas" - }); - } - catch (Win32Exception e) - { - if (e.NativeErrorCode == 0x4c7) - _logger.Warning("ApplyUpdate: Operation was cancelled, user likely clicked No in UAC dialog"); - else - throw; - } - } - - private async Task UpdateInstaller() - { - string installerDirectory = Path.Combine(Constants.DataFolder, "installer"); - string installerPath = Path.Combine(installerDirectory, "Artemis.Installer.exe"); - - _logger.Information("UpdateInstaller: Downloading installer from {DownloadUrl}", INSTALLER_URL); - using HttpClient client = new(); - HttpResponseMessage httpResponseMessage = await client.GetAsync(INSTALLER_URL); - if (!httpResponseMessage.IsSuccessStatusCode) - throw new ArtemisUIException($"Failed to download installer, status code {httpResponseMessage.StatusCode}"); - - _logger.Information("UpdateInstaller: Writing installer file to {InstallerPath}", installerPath); - if (File.Exists(installerPath)) - File.Delete(installerPath); - - Core.Utilities.CreateAccessibleDirectory(installerDirectory); - await using FileStream fs = new(installerPath, FileMode.Create, FileAccess.Write, FileShare.None); - await httpResponseMessage.Content.CopyToAsync(fs); - } - - private void ShowDesktopNotification(string channel) - { - new ToastContentBuilder() - .AddArgument("channel", channel) - .AddText("An update is available") - .AddButton(new ToastButton().SetContent("Install").AddArgument("action", "install").SetBackgroundActivation()) - .AddButton(new ToastButton().SetContent("View changes").AddArgument("action", "view-changes")) - .Show(); - } - - /// - public void Dispose() - { - ToastNotificationManagerCompat.OnActivated -= ToastNotificationManagerCompatOnOnActivated; - ToastNotificationManagerCompat.Uninstall(); - } - - /// - public async Task CheckForUpdate(string channel) - { - DevOpsBuild? buildInfo = await GetBuildInfo(1); - if (buildInfo == null) - return false; - - double buildNumber = double.Parse(buildInfo.BuildNumber, CultureInfo.InvariantCulture); - string buildNumberDisplay = buildNumber.ToString(CultureInfo.InvariantCulture); - _logger.Information("Latest build is {BuildNumber}, we're running {LocalBuildNumber}", buildNumberDisplay, Constants.BuildInfo.BuildNumberDisplay); - - return buildNumber > Constants.BuildInfo.BuildNumber; - } - - /// - public async Task ApplyUpdate(string channel, bool silent) - { - await RunInstaller(channel, silent); - } - - /// - public async Task OfferUpdate(string channel, bool windowOpen) - { - if (windowOpen) - { - bool update = await _windowService.ShowDialogAsync(channel); - if (update) - await RunInstaller(channel, false); - } - else - { - ShowDesktopNotification(channel); - } - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index 987233c0e..101b1ac8a 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -30,6 +30,7 @@ + diff --git a/src/Artemis.UI/Extensions/HttpClientExtensions.cs b/src/Artemis.UI/Extensions/HttpClientExtensions.cs new file mode 100644 index 000000000..50af33443 --- /dev/null +++ b/src/Artemis.UI/Extensions/HttpClientExtensions.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Artemis.UI.Extensions +{ + public static class HttpClientProgressExtensions + { + public static async Task DownloadDataAsync(this HttpClient client, string requestUrl, Stream destination, IProgress? progress, CancellationToken cancellationToken) + { + using HttpResponseMessage response = await client.GetAsync(requestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + long? contentLength = response.Content.Headers.ContentLength; + await using Stream download = await response.Content.ReadAsStreamAsync(cancellationToken); + // no progress... no contentLength... very sad + if (progress is null || !contentLength.HasValue) + { + await download.CopyToAsync(destination, cancellationToken); + return; + } + + // Such progress and contentLength much reporting Wow! + Progress progressWrapper = new(totalBytes => progress.Report(GetProgressPercentage(totalBytes, contentLength.Value))); + await download.CopyToAsync(destination, 81920, progressWrapper, cancellationToken); + + float GetProgressPercentage(float totalBytes, float currentBytes) => (totalBytes / currentBytes) * 100f; + } + + static async Task CopyToAsync(this Stream source, Stream destination, int bufferSize, IProgress progress, CancellationToken cancellationToken) + { + if (bufferSize < 0) + throw new ArgumentOutOfRangeException(nameof(bufferSize)); + if (source is null) + throw new ArgumentNullException(nameof(source)); + if (!source.CanRead) + throw new InvalidOperationException($"'{nameof(source)}' is not readable."); + if (destination == null) + throw new ArgumentNullException(nameof(destination)); + if (!destination.CanWrite) + throw new InvalidOperationException($"'{nameof(destination)}' is not writable."); + + byte[] buffer = new byte[bufferSize]; + long totalBytesRead = 0; + int bytesRead; + while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) + { + await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + totalBytesRead += bytesRead; + progress?.Report(totalBytesRead); + } + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/UpdateInstallationViewModel.cs b/src/Artemis.UI/Screens/Settings/Updating/UpdateInstallationViewModel.cs new file mode 100644 index 000000000..4b1950846 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Updating/UpdateInstallationViewModel.cs @@ -0,0 +1,14 @@ +using Artemis.Core; +using Artemis.UI.Shared; + +namespace Artemis.UI.Screens.Settings.Updating; + +public class UpdateInstallationViewModel : DialogViewModelBase +{ + private readonly string _nextReleaseId; + + public UpdateInstallationViewModel(string nextReleaseId) + { + _nextReleaseId = nextReleaseId; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Services/UpdateService.cs b/src/Artemis.UI/Services/UpdateService.cs deleted file mode 100644 index ab8e0e2ff..000000000 --- a/src/Artemis.UI/Services/UpdateService.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Threading.Tasks; -using System.Timers; -using Artemis.Core; -using Artemis.Core.Services; -using Artemis.UI.Services.Interfaces; -using Artemis.UI.Shared.Providers; -using Artemis.UI.Shared.Services.MainWindow; -using Avalonia.Threading; -using DryIoc; -using Serilog; - -namespace Artemis.UI.Services; - -public class UpdateService : IUpdateService -{ - private const double UPDATE_CHECK_INTERVAL = 3_600_000; // once per hour - - private readonly PluginSetting _autoUpdate; - private readonly PluginSetting _checkForUpdates; - private readonly ILogger _logger; - private readonly IMainWindowService _mainWindowService; - private readonly IUpdateProvider? _updateProvider; - - public UpdateService(ILogger logger, IContainer container, ISettingsService settingsService, IMainWindowService mainWindowService) - { - _logger = logger; - _mainWindowService = mainWindowService; - - if (!Constants.BuildInfo.IsLocalBuild) - _updateProvider = container.Resolve(IfUnresolved.ReturnDefault); - - _checkForUpdates = settingsService.GetSetting("UI.CheckForUpdates", true); - _autoUpdate = settingsService.GetSetting("UI.AutoUpdate", false); - _checkForUpdates.SettingChanged += CheckForUpdatesOnSettingChanged; - _mainWindowService.MainWindowOpened += WindowServiceOnMainWindowOpened; - - Timer timer = new(UPDATE_CHECK_INTERVAL); - timer.Elapsed += TimerOnElapsed; - timer.Start(); - } - - private async void TimerOnElapsed(object? sender, ElapsedEventArgs e) - { - await AutoUpdate(); - } - - private async void CheckForUpdatesOnSettingChanged(object? sender, EventArgs e) - { - // Run an auto-update as soon as the setting gets changed to enabled - if (_checkForUpdates.Value) - await AutoUpdate(); - } - - private async void WindowServiceOnMainWindowOpened(object? sender, EventArgs e) - { - await AutoUpdate(); - } - - private async Task AutoUpdate() - { - if (_updateProvider == null || !_checkForUpdates.Value || SuspendAutoUpdate) - return; - - try - { - bool updateAvailable = await _updateProvider.CheckForUpdate("master"); - if (!updateAvailable) - return; - - // Only offer it once per session - SuspendAutoUpdate = true; - - // If the window is open show the changelog, don't auto-update while the user is busy - if (_mainWindowService.IsMainWindowOpen) - { - await Dispatcher.UIThread.InvokeAsync(async () => - { - // Call OpenMainWindow anyway to focus the main window - _mainWindowService.OpenMainWindow(); - await _updateProvider.OfferUpdate("master", true); - }); - return; - } - - // If the window is closed but auto-update is enabled, update silently - if (_autoUpdate.Value) - await _updateProvider.ApplyUpdate("master", true); - // If auto-update is disabled the update provider can show a notification and handle the rest - else - await _updateProvider.OfferUpdate("master", false); - } - catch (Exception e) - { - _logger.Warning(e, "Auto update failed"); - } - } - - public bool SuspendAutoUpdate { get; set; } - public bool UpdatingSupported => _updateProvider != null; - - public async Task ManualUpdate() - { - if (_updateProvider == null || !_mainWindowService.IsMainWindowOpen) - return; - - bool updateAvailable = await _updateProvider.CheckForUpdate("master"); - if (!updateAvailable) - return; - - await _updateProvider.OfferUpdate("master", true); - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Services/Updating/IUpdateNotificationProvider.cs b/src/Artemis.UI/Services/Updating/IUpdateNotificationProvider.cs new file mode 100644 index 000000000..fcf306bb4 --- /dev/null +++ b/src/Artemis.UI/Services/Updating/IUpdateNotificationProvider.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Artemis.UI.Services.Updating; + +public interface IUpdateNotificationProvider +{ + Task ShowNotification(string releaseId); +} \ No newline at end of file diff --git a/src/Artemis.UI/Services/Interfaces/IUpdateService.cs b/src/Artemis.UI/Services/Updating/IUpdateService.cs similarity index 93% rename from src/Artemis.UI/Services/Interfaces/IUpdateService.cs rename to src/Artemis.UI/Services/Updating/IUpdateService.cs index cc6236853..157d1ef07 100644 --- a/src/Artemis.UI/Services/Interfaces/IUpdateService.cs +++ b/src/Artemis.UI/Services/Updating/IUpdateService.cs @@ -12,7 +12,7 @@ public interface IUpdateService : IArtemisUIService /// /// Gets or sets a boolean indicating whether auto-updating is suspended. /// - bool SuspendAutoUpdate { get; set; } + bool SuspendAutoCheck { get; set; } /// /// Manually checks for updates and offers to install it if found. diff --git a/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs b/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs new file mode 100644 index 000000000..f868298b7 --- /dev/null +++ b/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs @@ -0,0 +1,142 @@ +using System; +using System.Drawing.Drawing2D; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Reflection.Metadata; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.UI.Extensions; +using Artemis.WebClient.Updating; +using NoStringEvaluating.Functions.Math; +using Octodiff.Core; +using Octodiff.Diagnostics; +using Serilog; +using StrawberryShake; + +namespace Artemis.UI.Services.Updating; + +/// +/// Represents the installation process of a release +/// +public class ReleaseInstaller +{ + private readonly string _releaseId; + private readonly ILogger _logger; + private readonly IUpdatingClient _updatingClient; + private readonly HttpClient _httpClient; + private readonly Platform _updatePlatform; + private readonly string _dataFolder; + + public IProgress OverallProgress { get; } = new Progress(); + public IProgress StepProgress { get; } = new Progress(); + + public ReleaseInstaller(string releaseId, ILogger logger, IUpdatingClient updatingClient, HttpClient httpClient) + { + _releaseId = releaseId; + _logger = logger; + _updatingClient = updatingClient; + _httpClient = httpClient; + _dataFolder = Path.Combine(Constants.DataFolder, "updating"); + + if (OperatingSystem.IsWindows()) + _updatePlatform = Platform.Windows; + if (OperatingSystem.IsLinux()) + _updatePlatform = Platform.Linux; + else if (OperatingSystem.IsMacOS()) + _updatePlatform = Platform.Osx; + else + throw new PlatformNotSupportedException("Cannot auto update on the current platform"); + + if (!Directory.Exists(_dataFolder)) + Directory.CreateDirectory(_dataFolder); + } + + public async Task InstallAsync(CancellationToken cancellationToken) + { + OverallProgress.Report(0); + + _logger.Information("Retrieving details for release {ReleaseId}", _releaseId); + IOperationResult result = await _updatingClient.GetReleaseById.ExecuteAsync(_releaseId, cancellationToken); + result.EnsureNoErrors(); + + IGetReleaseById_Release? release = result.Data?.Release; + if (release == null) + throw new Exception($"Could not find release with ID {_releaseId}"); + + IGetReleaseById_Release_Artifacts? artifact = release.Artifacts.FirstOrDefault(a => a.Platform == _updatePlatform); + if (artifact == null) + throw new Exception("Found the release but it has no artifact for the current platform"); + + OverallProgress.Report(0.1f); + + // Determine whether the last update matches our local version, then we can download the delta + if (release.PreviousRelease != null && File.Exists(Path.Combine(_dataFolder, $"{release.PreviousRelease}.zip")) && artifact.DeltaFileInfo.DownloadSize != 0) + await DownloadDelta(artifact, Path.Combine(_dataFolder, $"{release.PreviousRelease}.zip"), cancellationToken); + else + await Download(artifact, cancellationToken); + } + + private async Task DownloadDelta(IGetReleaseById_Release_Artifacts artifact, string previousRelease, CancellationToken cancellationToken) + { + await using MemoryStream stream = new(); + await _httpClient.DownloadDataAsync($"https://updating.artemis-rgb.com/api/artifacts/download/{artifact.ArtifactId}/delta", stream, StepProgress, cancellationToken); + + OverallProgress.Report(0.33f); + + await PatchDelta(stream, previousRelease, cancellationToken); + } + + private async Task PatchDelta(MemoryStream deltaStream, string previousRelease, CancellationToken cancellationToken) + { + await using FileStream baseStream = File.OpenRead(previousRelease); + await using FileStream newFileStream = new(Path.Combine(_dataFolder, $"{_releaseId}.zip"), FileMode.Create, FileAccess.ReadWrite, FileShare.Read); + + deltaStream.Seek(0, SeekOrigin.Begin); + DeltaApplier deltaApplier = new(); + deltaApplier.Apply(baseStream, new BinaryDeltaReader(deltaStream, new DeltaApplierProgressReporter(StepProgress)), newFileStream); + cancellationToken.ThrowIfCancellationRequested(); + + OverallProgress.Report(0.66f); + await Extract(newFileStream, cancellationToken); + } + + private async Task Download(IGetReleaseById_Release_Artifacts artifact, CancellationToken cancellationToken) + { + await using MemoryStream stream = new(); + await _httpClient.DownloadDataAsync($"https://updating.artemis-rgb.com/api/artifacts/download/{artifact.ArtifactId}", stream, StepProgress, cancellationToken); + + OverallProgress.Report(0.5f); + } + + private async Task Extract(FileStream archiveStream, CancellationToken cancellationToken) + { + // Ensure the directory is empty + string extractDirectory = Path.Combine(_dataFolder, "pending"); + if (Directory.Exists(extractDirectory)) + Directory.Delete(extractDirectory, true); + Directory.CreateDirectory(extractDirectory); + + archiveStream.Seek(0, SeekOrigin.Begin); + using ZipArchive archive = new(archiveStream); + archive.ExtractToDirectory(extractDirectory); + OverallProgress.Report(1); + } +} + +internal class DeltaApplierProgressReporter : IProgressReporter +{ + private readonly IProgress _stepProgress; + + public DeltaApplierProgressReporter(IProgress stepProgress) + { + _stepProgress = stepProgress; + } + + public void ReportProgress(string operation, long currentPosition, long total) + { + _stepProgress.Report((float) currentPosition / total); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Services/Updating/SimpleUpdateNotificationProvider.cs b/src/Artemis.UI/Services/Updating/SimpleUpdateNotificationProvider.cs new file mode 100644 index 000000000..94bcaf3be --- /dev/null +++ b/src/Artemis.UI/Services/Updating/SimpleUpdateNotificationProvider.cs @@ -0,0 +1,6 @@ +namespace Artemis.UI.Services.Updating; + +public class SimpleUpdateNotificationProvider : IUpdateNotificationProvider +{ + +} \ No newline at end of file diff --git a/src/Artemis.UI/Services/Updating/UpdateService.cs b/src/Artemis.UI/Services/Updating/UpdateService.cs new file mode 100644 index 000000000..1077d549c --- /dev/null +++ b/src/Artemis.UI/Services/Updating/UpdateService.cs @@ -0,0 +1,147 @@ +using System; +using System.Linq; +using System.Reactive.Disposables; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.UI.Exceptions; +using Artemis.UI.Screens.Settings.Updating; +using Artemis.UI.Services.Interfaces; +using Artemis.UI.Services.Updating; +using Artemis.UI.Shared.Providers; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.MainWindow; +using Artemis.WebClient.Updating; +using Avalonia.Threading; +using DryIoc; +using Serilog; +using StrawberryShake; +using Timer = System.Timers.Timer; + +namespace Artemis.UI.Services; + +public class UpdateService : IUpdateService +{ + private const double UPDATE_CHECK_INTERVAL = 3_600_000; // once per hour + + private readonly ILogger _logger; + private readonly IMainWindowService _mainWindowService; + private readonly IWindowService _windowService; + private readonly IUpdatingClient _updatingClient; + private readonly Lazy _updateNotificationProvider; + private readonly Func _getReleaseInstaller; + private readonly Platform _updatePlatform; + private readonly PluginSetting _channel; + private readonly PluginSetting _autoCheck; + private readonly PluginSetting _autoInstall; + + private bool _suspendAutoCheck; + + public UpdateService(ILogger logger, + ISettingsService settingsService, + IMainWindowService mainWindowService, + IWindowService windowService, + IUpdatingClient updatingClient, + Lazy updateNotificationProvider, + Func getReleaseInstaller) + { + _logger = logger; + _mainWindowService = mainWindowService; + _windowService = windowService; + _updatingClient = updatingClient; + _updateNotificationProvider = updateNotificationProvider; + _getReleaseInstaller = getReleaseInstaller; + + if (OperatingSystem.IsWindows()) + _updatePlatform = Platform.Windows; + if (OperatingSystem.IsLinux()) + _updatePlatform = Platform.Linux; + else if (OperatingSystem.IsMacOS()) + _updatePlatform = Platform.Osx; + else + throw new PlatformNotSupportedException("Cannot auto update on the current platform"); + + _channel = settingsService.GetSetting("UI.Updating.Channel", "master"); + _autoCheck = settingsService.GetSetting("UI.Updating.AutoCheck", true); + _autoInstall = settingsService.GetSetting("UI.Updating.AutoInstall", false); + _autoCheck.SettingChanged += HandleAutoUpdateEvent; + _mainWindowService.MainWindowOpened += HandleAutoUpdateEvent; + Timer timer = new(UPDATE_CHECK_INTERVAL); + timer.Elapsed += HandleAutoUpdateEvent; + timer.Start(); + } + + public async Task CheckForUpdate() + { + string? currentVersion = AssemblyProductVersion; + if (currentVersion == null) + return false; + + IOperationResult result = await _updatingClient.GetNextRelease.ExecuteAsync(currentVersion, _channel.Value, _updatePlatform); + result.EnsureNoErrors(); + + // No update was found + if (result.Data?.NextRelease == null) + return false; + + // Only offer it once per session + _suspendAutoCheck = true; + + // If the window is open show the changelog, don't auto-update while the user is busy + if (_mainWindowService.IsMainWindowOpen) + await ShowUpdateDialog(result.Data.NextRelease.Id); + else if (!_autoInstall.Value) + await ShowUpdateNotification(result.Data.NextRelease.Id); + else + await AutoInstallUpdate(result.Data.NextRelease.Id); + + return true; + } + + private async Task ShowUpdateDialog(string nextReleaseId) + { + await Dispatcher.UIThread.InvokeAsync(async () => + { + // Main window is probably already open but this will bring it into focus + _mainWindowService.OpenMainWindow(); + await _windowService.ShowDialogAsync(nextReleaseId); + }); + } + + private async Task ShowUpdateNotification(string nextReleaseId) + { + await _updateNotificationProvider.Value.ShowNotification(nextReleaseId); + } + + private async Task AutoInstallUpdate(string nextReleaseId) + { + ReleaseInstaller installer = _getReleaseInstaller(nextReleaseId); + await installer.InstallAsync(CancellationToken.None); + } + + private async void HandleAutoUpdateEvent(object? sender, EventArgs e) + { + if (!_autoCheck.Value || _suspendAutoCheck) + return; + + try + { + await CheckForUpdate(); + } + catch (Exception ex) + { + _logger.Warning(ex, "Auto update failed"); + } + } + + private static string? AssemblyProductVersion + { + get + { + object[] attributes = typeof(UpdateService).Assembly.GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false); + return attributes.Length == 0 ? null : ((AssemblyInformationalVersionAttribute) attributes[0]).InformationalVersion; + } + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql b/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql index fa17ed4f0..cd7e8b2a8 100644 --- a/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql +++ b/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql @@ -1,16 +1,30 @@ -query GetReleaseById { - release(id: "63b71dd69a5bb32a0a81a410") { +query GetReleaseById($id: String!) { + release(id: $id) { branch commit version + previousRelease artifacts { platform artifactId fileInfo { - md5Hash - downloadSize - downloads + ...fileInfo + } + deltaFileInfo { + ...fileInfo } } } } + +fragment fileInfo on ArtifactFileInfo { + md5Hash + downloadSize +} + +query GetNextRelease($currentVersion: String!, $branch: String!, $platform: Platform!) { + nextRelease(version: $currentVersion, branch: $branch, platform: $platform) { + id + version + } +} \ No newline at end of file