diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml new file mode 100644 index 000000000..83dfdfa24 --- /dev/null +++ b/.github/workflows/master.yml @@ -0,0 +1,109 @@ +name: Master - Build & Upload to Ftp + +on: + workflow_dispatch: + push: + +jobs: + version: + runs-on: ubuntu-latest + outputs: + version-number: ${{ steps.get-version.outputs.version-number }} + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Get Version String + id: get-version + shell: pwsh + run: | + $MidnightUtc = [DateTime]::UtcNow.Date + $BranchName = "${{ github.ref_name }}".replace('/','-').replace('.','-') + $ApiVersion = (Select-Xml -Path 'src/Artemis.Core/Artemis.Core.csproj' -XPath '//PluginApiVersion').Node.InnerText + $NumberOfCommitsToday = (git log --after=$($MidnightUtc.ToString("o")) --oneline | Measure-Object -Line).Lines + $VersionNumber = "$ApiVersion.$($MidnightUtc.ToString("yyyy.MMdd")).$NumberOfCommitsToday" + # If we're not in master, add the branch name to the version so it counts as prerelease + if ($BranchName -ne "master") { $VersionNumber += "-$BranchName" } + "version-number=$VersionNumber" >> $Env:GITHUB_OUTPUT + + build: + needs: version + strategy: + matrix: + include: + - os: windows-latest + rid: win10-x64 + csproj: Windows + + - os: ubuntu-latest + rid: linux-x64 + csproj: Linux + + - os: macos-latest + rid: osx-x64 + csproj: MacOS + name: ${{ matrix.csproj }} Build + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Artemis + uses: actions/checkout@v3 + with: + path: Artemis + - name: Checkout Plugins + uses: actions/checkout@v3 + with: + repository: Artemis-RGB/Artemis.Plugins + path: Artemis.Plugins + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: '6.0.x' + - name: Publish Artemis + run: dotnet publish --configuration Release -p:Version=${{ needs.version.outputs.version-number }} --runtime ${{ matrix.rid }} --output build/${{ matrix.rid }} --self-contained Artemis/src/Artemis.UI.${{ matrix.csproj }}/Artemis.UI.${{ matrix.csproj }}.csproj + - name: Publish Plugins + run: | + New-Item -ItemType "Directory" -Path build/${{ matrix.rid }}/Plugins/ + Get-ChildItem -File -Recurse -Filter *.csproj Artemis.Plugins/src/ | + Foreach-Object -Parallel { + dotnet publish --configuration Release --runtime ${{ matrix.rid }} --output build-plugins/$($_.BaseName) --no-self-contained $($_.FullName); + Compress-Archive -Path "build-plugins/$($_.BaseName)" -DestinationPath "build/${{ matrix.rid }}/Plugins/$($_.BaseName).zip"; + } + shell: pwsh + - name: Upload Artifact + uses: actions/upload-artifact@v3 + with: + name: artemis-${{ matrix.rid }}-${{ needs.version.outputs.version-number }} + path: build/${{ matrix.rid }} + + notify: + name: Notify Backend of build + runs-on: ubuntu-latest + needs: build + steps: + - name: post thing + if: needs.build.result == 'success' + shell: pwsh + run: | + $tokenUri = "https://identity.artemis-rgb.com/connect/token" + + $headers = @{ + "Content-Type" = "application/x-www-form-urlencoded" + } + + $body = @{ + "grant_type" = "client_credentials" + "client_id" = "github.task-runners" + "client_secret" = "${{ secrets.UPDATE_SECRET }}" + "scope" = "artemis-updating.releases:retrieve" + } + + $response = Invoke-RestMethod -Method Post -Uri $tokenUri -Body $body -Headers $headers + $accessToken = $response.access_token + + $apiUri = "https://updating.artemis-rgb.com/api/github/retrieve-run/${{ github.run_id }}" + $authHeader = @{ + "Authorization" = "Bearer $accessToken" + } + + $updateResponse = Invoke-RestMethod -Method Post -Uri $apiUri -Headers $authHeader diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml index b6fe9fb50..fe329b9d9 100644 --- a/.github/workflows/nuget.yml +++ b/.github/workflows/nuget.yml @@ -27,7 +27,7 @@ jobs: $VersionNumber = "$ApiVersion.$($MidnightUtc.ToString("yyyy.MMdd")).$NumberOfCommitsToday" # If we're not in master, add the branch name to the version so it counts as prerelease if ($BranchName -ne "master") { $VersionNumber += "-$BranchName" } - Write-Output "::set-output name=version-number::$VersionNumber" + "version-number=$VersionNumber" >> $Env:GITHUB_OUTPUT nuget: name: Publish Nuget Packages diff --git a/src/Artemis.Core/Constants.cs b/src/Artemis.Core/Constants.cs index cfb3b0551..fd0300579 100644 --- a/src/Artemis.Core/Constants.cs +++ b/src/Artemis.Core/Constants.cs @@ -48,6 +48,10 @@ public static class Constants /// The full path to the Artemis logs folder /// public static readonly string LogsFolder = Path.Combine(DataFolder, "Logs"); + /// + /// The full path to the Artemis logs folder + /// + public static readonly string UpdatingFolder = Path.Combine(DataFolder, "updating"); /// /// The full path to the Artemis plugins folder @@ -62,32 +66,24 @@ public static class Constants /// /// The current API version for plugins /// - public static readonly int PluginApiVersion = int.Parse(CoreAssembly.GetCustomAttributes().First(a => a.Key == "PluginApiVersion").Value ?? + public static readonly int PluginApiVersion = int.Parse(CoreAssembly.GetCustomAttributes().FirstOrDefault(a => a.Key == "PluginApiVersion")?.Value ?? throw new InvalidOperationException("Cannot find PluginApiVersion metadata in assembly")); + /// + /// The current version of the application + /// + public static readonly string CurrentVersion = CoreAssembly.GetCustomAttribute()!.InformationalVersion != "1.0.0" + ? CoreAssembly.GetCustomAttribute()!.InformationalVersion + : "local"; + /// /// The plugin info used by core components of Artemis /// public static readonly PluginInfo CorePluginInfo = new() { - Guid = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"), Name = "Artemis Core", Version = new Version(2, 0) + Guid = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff"), Name = "Artemis Core", Version = CurrentVersion }; - /// - /// The build information related to the currently running Artemis build - /// Information is retrieved from buildinfo.json - /// - public static readonly BuildInfo BuildInfo = File.Exists(Path.Combine(ApplicationFolder, "buildinfo.json")) - ? JsonConvert.DeserializeObject(File.ReadAllText(Path.Combine(ApplicationFolder, "buildinfo.json")))! - : new BuildInfo - { - IsLocalBuild = true, - BuildId = 1337, - BuildNumber = 1337, - SourceBranch = "local", - SourceVersion = "local" - }; - /// /// The plugin used by core components of Artemis /// diff --git a/src/Artemis.Core/Events/UpdateEventArgs.cs b/src/Artemis.Core/Events/UpdateEventArgs.cs new file mode 100644 index 000000000..d513bae5e --- /dev/null +++ b/src/Artemis.Core/Events/UpdateEventArgs.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace Artemis.Core; + +/// +/// Provides data about application update events +/// +public class UpdateEventArgs : EventArgs +{ + internal UpdateEventArgs(bool silent) + { + Silent = silent; + } + + /// + /// Gets a boolean indicating whether to silently update or not. + /// + public bool Silent { get; } +} \ No newline at end of file diff --git a/src/Artemis.Core/Plugins/PluginInfo.cs b/src/Artemis.Core/Plugins/PluginInfo.cs index 43b8319c0..6421749f8 100644 --- a/src/Artemis.Core/Plugins/PluginInfo.cs +++ b/src/Artemis.Core/Plugins/PluginInfo.cs @@ -25,7 +25,7 @@ public class PluginInfo : CorePropertyChanged, IPrerequisitesSubject private Plugin _plugin = null!; private Uri? _repository; private bool _requiresAdmin; - private Version _version = null!; + private string _version = null!; private Uri? _website; internal PluginInfo() @@ -107,7 +107,7 @@ public class PluginInfo : CorePropertyChanged, IPrerequisitesSubject /// The version of the plugin /// [JsonProperty(Required = Required.Always)] - public Version Version + public string Version { get => _version; internal set => SetAndNotify(ref _version, value); diff --git a/src/Artemis.Core/Services/CoreService.cs b/src/Artemis.Core/Services/CoreService.cs index 933d3d3c3..ee1352c8d 100644 --- a/src/Artemis.Core/Services/CoreService.cs +++ b/src/Artemis.Core/Services/CoreService.cs @@ -200,23 +200,17 @@ internal class CoreService : ICoreService if (IsInitialized) throw new ArtemisCoreException("Cannot initialize the core as it is already initialized."); - AssemblyInformationalVersionAttribute? versionAttribute = typeof(CoreService).Assembly.GetCustomAttribute(); - _logger.Information( - "Initializing Artemis Core version {version}, build {buildNumber} branch {branch}.", - versionAttribute?.InformationalVersion, - Constants.BuildInfo.BuildNumber, - Constants.BuildInfo.SourceBranch - ); - _logger.Information("Startup arguments: {args}", Constants.StartupArguments); - _logger.Information("Elevated permissions: {perms}", IsElevated); - _logger.Information("Stopwatch high resolution: {perms}", Stopwatch.IsHighResolution); + _logger.Information("Initializing Artemis Core version {CurrentVersion}", Constants.CurrentVersion); + _logger.Information("Startup arguments: {StartupArguments}", Constants.StartupArguments); + _logger.Information("Elevated permissions: {IsElevated}", IsElevated); + _logger.Information("Stopwatch high resolution: {IsHighResolution}", Stopwatch.IsHighResolution); ApplyLoggingLevel(); // Don't remove even if it looks useless // Just this line should prevent a certain someone from removing HidSharp as an unused dependency as well Version? hidSharpVersion = Assembly.GetAssembly(typeof(HidDevice))!.GetName().Version; - _logger.Debug("Forcing plugins to use HidSharp {hidSharpVersion}", hidSharpVersion); + _logger.Debug("Forcing plugins to use HidSharp {HidSharpVersion}", hidSharpVersion); // Initialize the services _pluginManagementService.CopyBuiltInPlugins(); diff --git a/src/Artemis.Core/Services/PluginManagementService.cs b/src/Artemis.Core/Services/PluginManagementService.cs index 6de473234..06b9b6368 100644 --- a/src/Artemis.Core/Services/PluginManagementService.cs +++ b/src/Artemis.Core/Services/PluginManagementService.cs @@ -47,15 +47,29 @@ internal class PluginManagementService : IPluginManagementService private void CopyBuiltInPlugin(ZipArchive zipArchive, string targetDirectory) { + ZipArchiveEntry metaDataFileEntry = zipArchive.Entries.First(e => e.Name == "plugin.json"); DirectoryInfo pluginDirectory = new(Path.Combine(Constants.PluginsFolder, targetDirectory)); bool createLockFile = File.Exists(Path.Combine(pluginDirectory.FullName, "artemis.lock")); // Remove the old directory if it exists if (Directory.Exists(pluginDirectory.FullName)) pluginDirectory.DeleteRecursively(); - Directory.CreateDirectory(pluginDirectory.FullName); - zipArchive.ExtractToDirectory(pluginDirectory.FullName, true); + // Extract everything in the same archive directory to the unique plugin directory + Utilities.CreateAccessibleDirectory(pluginDirectory.FullName); + string metaDataDirectory = metaDataFileEntry.FullName.Replace(metaDataFileEntry.Name, ""); + foreach (ZipArchiveEntry zipArchiveEntry in zipArchive.Entries) + { + if (zipArchiveEntry.FullName.StartsWith(metaDataDirectory) && !zipArchiveEntry.FullName.EndsWith("/")) + { + string target = Path.Combine(pluginDirectory.FullName, zipArchiveEntry.FullName.Remove(0, metaDataDirectory.Length)); + // Create folders + Utilities.CreateAccessibleDirectory(Path.GetDirectoryName(target)!); + // Extract files + zipArchiveEntry.ExtractToFile(target); + } + } + if (createLockFile) File.Create(Path.Combine(pluginDirectory.FullName, "artemis.lock")).Close(); } @@ -82,57 +96,57 @@ internal class PluginManagementService : IPluginManagementService return; } + foreach (FileInfo zipFile in builtInPluginDirectory.EnumerateFiles("*.zip")) { - // Find the metadata file in the zip - using ZipArchive archive = ZipFile.OpenRead(zipFile.FullName); - ZipArchiveEntry? metaDataFileEntry = archive.GetEntry("plugin.json"); - if (metaDataFileEntry == null) - throw new ArtemisPluginException("Couldn't find a plugin.json in " + zipFile.FullName); - - using StreamReader reader = new(metaDataFileEntry.Open()); - PluginInfo builtInPluginInfo = CoreJson.DeserializeObject(reader.ReadToEnd())!; - string preferred = builtInPluginInfo.PreferredPluginDirectory; - - // Find the matching plugin in the plugin folder - DirectoryInfo? match = pluginDirectory.EnumerateDirectories().FirstOrDefault(d => d.Name == preferred); - if (match == null) + try { + ExtractBuiltInPlugin(zipFile, pluginDirectory); + } + catch (Exception e) + { + _logger.Error(e, "Failed to copy built-in plugin from {ZipFile}", zipFile.FullName); + } + } + } + + private void ExtractBuiltInPlugin(FileInfo zipFile, DirectoryInfo pluginDirectory) + { + // Find the metadata file in the zip + using ZipArchive archive = ZipFile.OpenRead(zipFile.FullName); + + ZipArchiveEntry? metaDataFileEntry = archive.Entries.FirstOrDefault(e => e.Name == "plugin.json"); + if (metaDataFileEntry == null) + throw new ArtemisPluginException("Couldn't find a plugin.json in " + zipFile.FullName); + + using StreamReader reader = new(metaDataFileEntry.Open()); + PluginInfo builtInPluginInfo = CoreJson.DeserializeObject(reader.ReadToEnd())!; + string preferred = builtInPluginInfo.PreferredPluginDirectory; + + // Find the matching plugin in the plugin folder + DirectoryInfo? match = pluginDirectory.EnumerateDirectories().FirstOrDefault(d => d.Name == preferred); + if (match == null) + { + CopyBuiltInPlugin(archive, preferred); + } + else + { + string metadataFile = Path.Combine(match.FullName, "plugin.json"); + if (!File.Exists(metadataFile)) + { + _logger.Debug("Copying missing built-in plugin {builtInPluginInfo}", builtInPluginInfo); CopyBuiltInPlugin(archive, preferred); } - else + else if (metaDataFileEntry.LastWriteTime > File.GetLastWriteTime(metadataFile)) { - string metadataFile = Path.Combine(match.FullName, "plugin.json"); - if (!File.Exists(metadataFile)) + try { - _logger.Debug("Copying missing built-in plugin {builtInPluginInfo}", builtInPluginInfo); + _logger.Debug("Copying updated built-in plugin {builtInPluginInfo}", builtInPluginInfo); CopyBuiltInPlugin(archive, preferred); } - else + catch (Exception e) { - PluginInfo pluginInfo; - try - { - // Compare versions, copy if the same when debugging - pluginInfo = CoreJson.DeserializeObject(File.ReadAllText(metadataFile))!; - } - catch (Exception e) - { - throw new ArtemisPluginException($"Failed read plugin metadata needed to install built-in plugin: {e.Message}", e); - } - - try - { - if (builtInPluginInfo.Version > pluginInfo.Version) - { - _logger.Debug("Copying updated built-in plugin from {pluginInfo} to {builtInPluginInfo}", pluginInfo, builtInPluginInfo); - CopyBuiltInPlugin(archive, preferred); - } - } - catch (Exception e) - { - throw new ArtemisPluginException($"Failed to install built-in plugin: {e.Message}", e); - } + throw new ArtemisPluginException($"Failed to install built-in plugin: {e.Message}", e); } } } @@ -197,7 +211,7 @@ internal class PluginManagementService : IPluginManagementService // Disposal happens manually before container disposal but the container doesn't know that so a 2nd call will be made if (_disposed) return; - + _disposed = true; UnloadPlugins(); } @@ -556,7 +570,6 @@ internal class PluginManagementService : IPluginManagementService if (metaDataFileEntry == null) throw new ArtemisPluginException("Couldn't find a plugin.json in " + fileName); - using StreamReader reader = new(metaDataFileEntry.Open()); PluginInfo pluginInfo = CoreJson.DeserializeObject(reader.ReadToEnd())!; if (!pluginInfo.Main.EndsWith(".dll")) diff --git a/src/Artemis.Core/Services/Storage/ProfileService.cs b/src/Artemis.Core/Services/Storage/ProfileService.cs index 4b86f3d55..ba1f496e7 100644 --- a/src/Artemis.Core/Services/Storage/ProfileService.cs +++ b/src/Artemis.Core/Services/Storage/ProfileService.cs @@ -109,22 +109,26 @@ internal class ProfileService : IProfileService if (profileConfiguration.HotkeyMode == ProfileConfigurationHotkeyMode.None) return; + bool before = profileConfiguration.IsSuspended; foreach (ArtemisKeyboardKeyEventArgs e in _pendingKeyboardEvents) { if (profileConfiguration.HotkeyMode == ProfileConfigurationHotkeyMode.Toggle) { - if (profileConfiguration.EnableHotkey != null && - profileConfiguration.EnableHotkey.MatchesEventArgs(e)) + if (profileConfiguration.EnableHotkey != null && profileConfiguration.EnableHotkey.MatchesEventArgs(e)) profileConfiguration.IsSuspended = !profileConfiguration.IsSuspended; } else { if (profileConfiguration.IsSuspended && profileConfiguration.EnableHotkey != null && profileConfiguration.EnableHotkey.MatchesEventArgs(e)) profileConfiguration.IsSuspended = false; - if (!profileConfiguration.IsSuspended && profileConfiguration.DisableHotkey != null && profileConfiguration.DisableHotkey.MatchesEventArgs(e)) + else if (!profileConfiguration.IsSuspended && profileConfiguration.DisableHotkey != null && profileConfiguration.DisableHotkey.MatchesEventArgs(e)) profileConfiguration.IsSuspended = true; } } + + // If suspension was changed, save the category + if (before != profileConfiguration.IsSuspended) + SaveProfileCategory(profileConfiguration.Category); } private void CreateDefaultProfileCategories() @@ -422,7 +426,7 @@ internal class ProfileService : IProfileService _profileCategories.Remove(profileCategory); _profileCategoryRepository.Remove(profileCategory.Entity); } - + OnProfileCategoryRemoved(new ProfileCategoryEventArgs(profileCategory)); } diff --git a/src/Artemis.Core/Utilities/Utilities.cs b/src/Artemis.Core/Utilities/Utilities.cs index bbb38c4be..db6e373a0 100644 --- a/src/Artemis.Core/Utilities/Utilities.cs +++ b/src/Artemis.Core/Utilities/Utilities.cs @@ -21,6 +21,7 @@ public static class Utilities CreateAccessibleDirectory(Constants.DataFolder); CreateAccessibleDirectory(Constants.PluginsFolder); CreateAccessibleDirectory(Constants.LayoutsFolder); + CreateAccessibleDirectory(Constants.UpdatingFolder); } /// @@ -50,6 +51,15 @@ public static class Utilities OnRestartRequested(new RestartEventArgs(elevate, delay, extraArgs.ToList())); } + /// + /// Applies a pending update + /// + /// A boolean indicating whether to silently update or not. + public static void ApplyUpdate(bool silent) + { + OnUpdateRequested(new UpdateEventArgs(silent)); + } + /// /// Opens the provided URL in the default web browser /// @@ -96,11 +106,16 @@ public static class Utilities /// Occurs when the core has requested an application shutdown /// public static event EventHandler? ShutdownRequested; - + /// /// Occurs when the core has requested an application restart /// public static event EventHandler? RestartRequested; + + /// + /// Occurs when the core has requested a pending application update to be applied + /// + public static event EventHandler? UpdateRequested; /// /// Opens the provided folder in the user's file explorer @@ -136,6 +151,11 @@ public static class Utilities { ShutdownRequested?.Invoke(null, EventArgs.Empty); } + + private static void OnUpdateRequested(UpdateEventArgs e) + { + UpdateRequested?.Invoke(null, e); + } #region Scaling diff --git a/src/Artemis.Storage/Entities/General/ReleaseEntity.cs b/src/Artemis.Storage/Entities/General/ReleaseEntity.cs new file mode 100644 index 000000000..7c517ff79 --- /dev/null +++ b/src/Artemis.Storage/Entities/General/ReleaseEntity.cs @@ -0,0 +1,11 @@ +using System; + +namespace Artemis.Storage.Entities.General; + +public class ReleaseEntity +{ + public Guid Id { get; set; } + + public string Version { get; set; } + public DateTimeOffset? InstalledAt { get; set; } +} \ No newline at end of file diff --git a/src/Artemis.Storage/Repositories/Interfaces/IQueuedActionRepository.cs b/src/Artemis.Storage/Repositories/Interfaces/IQueuedActionRepository.cs index dfcbfbfe7..cb5852eaa 100644 --- a/src/Artemis.Storage/Repositories/Interfaces/IQueuedActionRepository.cs +++ b/src/Artemis.Storage/Repositories/Interfaces/IQueuedActionRepository.cs @@ -9,4 +9,6 @@ public interface IQueuedActionRepository : IRepository void Remove(QueuedActionEntity queuedActionEntity); List GetAll(); List GetByType(string type); + bool IsTypeQueued(string type); + void ClearByType(string type); } \ No newline at end of file diff --git a/src/Artemis.Storage/Repositories/QueuedActionRepository.cs b/src/Artemis.Storage/Repositories/QueuedActionRepository.cs index faa6a304f..f5c83cd0e 100644 --- a/src/Artemis.Storage/Repositories/QueuedActionRepository.cs +++ b/src/Artemis.Storage/Repositories/QueuedActionRepository.cs @@ -41,5 +41,17 @@ public class QueuedActionRepository : IQueuedActionRepository return _repository.Query().Where(q => q.Type == type).ToList(); } + /// + public bool IsTypeQueued(string type) + { + return _repository.Query().Where(q => q.Type == type).Count() > 0; + } + + /// + public void ClearByType(string type) + { + _repository.DeleteMany(q => q.Type == type); + } + #endregion } \ No newline at end of file diff --git a/src/Artemis.Storage/Repositories/ReleaseRepository.cs b/src/Artemis.Storage/Repositories/ReleaseRepository.cs new file mode 100644 index 000000000..c5c18ae27 --- /dev/null +++ b/src/Artemis.Storage/Repositories/ReleaseRepository.cs @@ -0,0 +1,38 @@ +using System; +using Artemis.Storage.Entities.General; +using Artemis.Storage.Repositories.Interfaces; +using LiteDB; + +namespace Artemis.Storage.Repositories; + +public class ReleaseRepository : IReleaseRepository +{ + private readonly LiteRepository _repository; + + public ReleaseRepository(LiteRepository repository) + { + _repository = repository; + _repository.Database.GetCollection().EnsureIndex(s => s.Version, true); + } + + public bool SaveVersionInstallDate(string version) + { + ReleaseEntity release = _repository.Query().Where(r => r.Version == version).FirstOrDefault(); + if (release != null) + return false; + + _repository.Insert(new ReleaseEntity {Version = version, InstalledAt = DateTimeOffset.UtcNow}); + return true; + } + + public ReleaseEntity GetPreviousInstalledVersion() + { + return _repository.Query().OrderByDescending(r => r.InstalledAt).Skip(1).FirstOrDefault(); + } +} + +public interface IReleaseRepository : IRepository +{ + bool SaveVersionInstallDate(string version); + ReleaseEntity GetPreviousInstalledVersion(); +} \ No newline at end of file diff --git a/src/Artemis.Storage/StorageManager.cs b/src/Artemis.Storage/StorageManager.cs index 87bc97b76..78fcc3643 100644 --- a/src/Artemis.Storage/StorageManager.cs +++ b/src/Artemis.Storage/StorageManager.cs @@ -30,7 +30,7 @@ public static class StorageManager { FileSystemInfo newest = files.OrderByDescending(fi => fi.CreationTime).First(); FileSystemInfo oldest = files.OrderBy(fi => fi.CreationTime).First(); - if (DateTime.Now - newest.CreationTime < TimeSpan.FromMinutes(10)) + if (DateTime.Now - newest.CreationTime < TimeSpan.FromHours(12)) return; oldest.Delete(); diff --git a/src/Artemis.UI.Shared/Converters/BytesToStringConverter.cs b/src/Artemis.UI.Shared/Converters/BytesToStringConverter.cs new file mode 100644 index 000000000..f6f7da7e6 --- /dev/null +++ b/src/Artemis.UI.Shared/Converters/BytesToStringConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Humanizer; +using Humanizer.Bytes; + +namespace Artemis.UI.Shared.Converters; + +/// +/// Converts bytes to a string +/// +public class BytesToStringConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is int intBytes) + return intBytes.Bytes().Humanize(); + if (value is long longBytes) + return longBytes.Bytes().Humanize(); + if (value is double doubleBytes) + return doubleBytes.Bytes().Humanize(); + + return value; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string formatted && ByteSize.TryParse(formatted, out ByteSize result)) + return result.Bytes; + return value; + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Shared/DataModelVisualization/Shared/DataModelVisualizationViewModel.cs b/src/Artemis.UI.Shared/DataModelVisualization/Shared/DataModelVisualizationViewModel.cs index 029a6dc18..59ceef437 100644 --- a/src/Artemis.UI.Shared/DataModelVisualization/Shared/DataModelVisualizationViewModel.cs +++ b/src/Artemis.UI.Shared/DataModelVisualization/Shared/DataModelVisualizationViewModel.cs @@ -163,21 +163,16 @@ public abstract class DataModelVisualizationViewModel : ReactiveObject, IDisposa /// /// Whether the type may be a loose match, meaning it can be cast or converted /// The types to filter - public void ApplyTypeFilter(bool looseMatch, params Type[]? filteredTypes) + public void ApplyTypeFilter(bool looseMatch, params Type?[]? filteredTypes) { if (filteredTypes != null) - { - if (filteredTypes.All(t => t == null)) - filteredTypes = null; - else - filteredTypes = filteredTypes.Where(t => t != null).ToArray(); - } + filteredTypes = filteredTypes.All(t => t == null) ? null : filteredTypes.Where(t => t != null).ToArray(); // If the VM has children, its own type is not relevant if (Children.Any()) { foreach (DataModelVisualizationViewModel child in Children) - child?.ApplyTypeFilter(looseMatch, filteredTypes); + child.ApplyTypeFilter(looseMatch, filteredTypes); IsMatchingFilteredTypes = true; return; @@ -199,7 +194,7 @@ public abstract class DataModelVisualizationViewModel : ReactiveObject, IDisposa } if (looseMatch) - IsMatchingFilteredTypes = filteredTypes.Any(t => t.IsCastableFrom(type) || + IsMatchingFilteredTypes = filteredTypes.Any(t => t!.IsCastableFrom(type) || (t == typeof(Enum) && type.IsEnum) || (t == typeof(IEnumerable<>) && type.IsGenericEnumerable()) || (type.IsGenericType && t == type.GetGenericTypeDefinition())); @@ -287,7 +282,7 @@ public abstract class DataModelVisualizationViewModel : ReactiveObject, IDisposa foreach (PropertyInfo propertyInfo in modelType.GetProperties(BindingFlags.Public | BindingFlags.Instance).OrderBy(t => t.MetadataToken)) { string childPath = AppendToPath(propertyInfo.Name); - if (Children.Any(c => c?.Path != null && c.Path.Equals(childPath))) + if (Children.Any(c => c.Path != null && c.Path.Equals(childPath))) continue; if (propertyInfo.GetCustomAttribute() != null) continue; 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.Shared/Services/MainWindow/IMainWindowService.cs b/src/Artemis.UI.Shared/Services/MainWindow/IMainWindowService.cs index bf7716f0b..e42d644dc 100644 --- a/src/Artemis.UI.Shared/Services/MainWindow/IMainWindowService.cs +++ b/src/Artemis.UI.Shared/Services/MainWindow/IMainWindowService.cs @@ -1,4 +1,5 @@ using System; +using ReactiveUI; namespace Artemis.UI.Shared.Services.MainWindow; @@ -12,6 +13,11 @@ public interface IMainWindowService : IArtemisSharedUIService /// bool IsMainWindowOpen { get; } + /// + /// Gets or sets the host screen contained in the main window + /// + IScreen? HostScreen { get; set; } + /// /// Sets up the main window provider that controls the state of the main window /// diff --git a/src/Artemis.UI.Shared/Services/MainWindow/MainWindowService.cs b/src/Artemis.UI.Shared/Services/MainWindow/MainWindowService.cs index fbdda52e1..98a6cba19 100644 --- a/src/Artemis.UI.Shared/Services/MainWindow/MainWindowService.cs +++ b/src/Artemis.UI.Shared/Services/MainWindow/MainWindowService.cs @@ -1,4 +1,5 @@ using System; +using ReactiveUI; namespace Artemis.UI.Shared.Services.MainWindow; @@ -6,6 +7,12 @@ internal class MainWindowService : IMainWindowService { private IMainWindowProvider? _mainWindowManager; + /// + public bool IsMainWindowOpen { get; private set; } + + /// + public IScreen? HostScreen { get; set; } + protected virtual void OnMainWindowOpened() { MainWindowOpened?.Invoke(this, EventArgs.Empty); @@ -64,8 +71,6 @@ internal class MainWindowService : IMainWindowService OnMainWindowUnfocused(); } - public bool IsMainWindowOpen { get; private set; } - public void ConfigureMainWindowProvider(IMainWindowProvider mainWindowProvider) { if (mainWindowProvider == null) throw new ArgumentNullException(nameof(mainWindowProvider)); diff --git a/src/Artemis.UI.Shared/Styles/Artemis.axaml b/src/Artemis.UI.Shared/Styles/Artemis.axaml index 97dafc8a1..7b838cdbf 100644 --- a/src/Artemis.UI.Shared/Styles/Artemis.axaml +++ b/src/Artemis.UI.Shared/Styles/Artemis.axaml @@ -21,6 +21,7 @@ + diff --git a/src/Artemis.UI.Shared/Styles/Skeleton.axaml b/src/Artemis.UI.Shared/Styles/Skeleton.axaml new file mode 100644 index 000000000..2596c3fe6 --- /dev/null +++ b/src/Artemis.UI.Shared/Styles/Skeleton.axaml @@ -0,0 +1,174 @@ + + + + + + + + + + This is heading 1 + This is heading 2 + This is heading 3 + This is heading 4 + This is heading 5 + This is heading 6 + This is regular text + This is regular text + This is regular text + + + + + + + + + + + + + + + + + + + + + This is heading 1 + This is heading 2 + This is heading 3 + This is heading 4 + This is heading 5 + This is heading 6 + This is regular text + + + + + + + + + + + + + + + + + + + + + This is heading 1 + This is heading 2 + This is heading 3 + This is heading 4 + This is heading 5 + This is heading 6 + This is regular text + + + + + + + 8 + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI.Windows/App.axaml.cs b/src/Artemis.UI.Windows/App.axaml.cs index b2a8db3df..9444327c6 100644 --- a/src/Artemis.UI.Windows/App.axaml.cs +++ b/src/Artemis.UI.Windows/App.axaml.cs @@ -43,9 +43,9 @@ public class App : Application { if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop || Design.IsDesignMode || _shutDown) return; - - ArtemisBootstrapper.Initialize(); + _applicationStateManager = new ApplicationStateManager(_container!, desktop.Args); + ArtemisBootstrapper.Initialize(); RegisterProviders(_container!); } diff --git a/src/Artemis.UI.Windows/ApplicationStateManager.cs b/src/Artemis.UI.Windows/ApplicationStateManager.cs index a14b50248..2d13fe49e 100644 --- a/src/Artemis.UI.Windows/ApplicationStateManager.cs +++ b/src/Artemis.UI.Windows/ApplicationStateManager.cs @@ -17,7 +17,7 @@ namespace Artemis.UI.Windows; public class ApplicationStateManager { private const int SM_SHUTTINGDOWN = 0x2000; - + public ApplicationStateManager(IContainer container, string[] startupArguments) { StartupArguments = startupArguments; @@ -25,6 +25,7 @@ public class ApplicationStateManager Core.Utilities.ShutdownRequested += UtilitiesOnShutdownRequested; Core.Utilities.RestartRequested += UtilitiesOnRestartRequested; + Core.Utilities.UpdateRequested += UtilitiesOnUpdateRequested; // On Windows shutdown dispose the IOC container just so device providers get a chance to clean up if (Application.Current?.ApplicationLifetime is IControlledApplicationLifetime controlledApplicationLifetime) @@ -91,6 +92,25 @@ public class ApplicationStateManager Dispatcher.UIThread.Post(() => controlledApplicationLifetime.Shutdown()); } + private void UtilitiesOnUpdateRequested(object? sender, UpdateEventArgs e) + { + List argsList = new(StartupArguments); + if (e.Silent && !argsList.Contains("--minimized")) + argsList.Add("--minimized"); + + // Retain startup arguments after update by providing them to the script + string script = Path.Combine(Constants.UpdatingFolder, "installing", "scripts", "update.ps1"); + string source = $"-sourceDirectory \"'{Path.Combine(Constants.UpdatingFolder, "installing")}'\""; + string destination = $"-destinationDirectory \"'{Constants.ApplicationFolder}'\""; + string args = argsList.Any() ? $"-artemisArgs \"'{string.Join(' ', argsList)}'\"" : ""; + + RunScriptWithOutputFile(script, $"{source} {destination} {args}", Path.Combine(Constants.DataFolder, "update-log.txt")); + + // Lets try a graceful shutdown, PowerShell will kill if needed + if (Application.Current?.ApplicationLifetime is IControlledApplicationLifetime controlledApplicationLifetime) + Dispatcher.UIThread.Post(() => controlledApplicationLifetime.Shutdown()); + } + private void UtilitiesOnShutdownRequested(object? sender, EventArgs e) { // Use PowerShell to kill the process after 8 sec just in case @@ -115,7 +135,21 @@ public class ApplicationStateManager }; Process.Start(info); } - + + private void RunScriptWithOutputFile(string script, string arguments, string outputFile) + { + // Use > for files that are bigger than 200kb to start fresh, otherwise use >> to append + string redirectSymbol = File.Exists(outputFile) && new FileInfo(outputFile).Length > 200000 ? ">" : ">>"; + ProcessStartInfo info = new() + { + Arguments = $"PowerShell -ExecutionPolicy Bypass -File \"{script}\" {arguments} {redirectSymbol} \"{outputFile}\"", + FileName = "PowerShell.exe", + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + }; + Process.Start(info); + } + [System.Runtime.InteropServices.DllImport("user32.dll")] private static extern int GetSystemMetrics(int nIndex); } \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Artemis.UI.Windows.csproj b/src/Artemis.UI.Windows/Artemis.UI.Windows.csproj index 74ef13f12..96d6d57fb 100644 --- a/src/Artemis.UI.Windows/Artemis.UI.Windows.csproj +++ b/src/Artemis.UI.Windows/Artemis.UI.Windows.csproj @@ -12,20 +12,10 @@ - - - - - - - - - - - - - - + + + PreserveNewest + application.ico diff --git a/src/Artemis.UI.Windows/DryIoc/ContainerExtensions.cs b/src/Artemis.UI.Windows/DryIoc/ContainerExtensions.cs index 000931d92..337e21744 100644 --- a/src/Artemis.UI.Windows/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.UI.Windows/DryIoc/ContainerExtensions.cs @@ -1,5 +1,6 @@ using Artemis.Core.Providers; using Artemis.Core.Services; +using Artemis.UI.Services.Updating; using Artemis.UI.Shared.Providers; using Artemis.UI.Windows.Providers; using Artemis.UI.Windows.Providers.Input; @@ -20,8 +21,8 @@ public static class UIContainerExtensions { container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); - container.Register(Reuse.Singleton); container.Register(); container.Register(serviceKey: WindowsInputProvider.Id); + container.Register(); } } \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Models/DevOpsBuilds.cs b/src/Artemis.UI.Windows/Models/DevOpsBuilds.cs deleted file mode 100644 index 8bff29ad5..000000000 --- a/src/Artemis.UI.Windows/Models/DevOpsBuilds.cs +++ /dev/null @@ -1,278 +0,0 @@ -#nullable disable - -using System; -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace Artemis.UI.Windows.Models; - -public class DevOpsBuilds -{ - [JsonProperty("count")] - public long Count { get; set; } - - [JsonProperty("value")] - public List Builds { get; set; } -} - -public class DevOpsBuild -{ - [JsonProperty("_links")] - public BuildLinks Links { get; set; } - - [JsonProperty("properties")] - public Properties Properties { get; set; } - - [JsonProperty("tags")] - public List Tags { get; set; } - - [JsonProperty("validationResults")] - public List ValidationResults { get; set; } - - [JsonProperty("plans")] - public List Plans { get; set; } - - [JsonProperty("triggerInfo")] - public TriggerInfo TriggerInfo { get; set; } - - [JsonProperty("id")] - public long Id { get; set; } - - [JsonProperty("buildNumber")] - public string BuildNumber { get; set; } - - [JsonProperty("status")] - public string Status { get; set; } - - [JsonProperty("result")] - public string Result { get; set; } - - [JsonProperty("queueTime")] - public DateTimeOffset QueueTime { get; set; } - - [JsonProperty("startTime")] - public DateTimeOffset StartTime { get; set; } - - [JsonProperty("finishTime")] - public DateTimeOffset FinishTime { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } - - [JsonProperty("definition")] - public Definition Definition { get; set; } - - [JsonProperty("buildNumberRevision")] - public long BuildNumberRevision { get; set; } - - [JsonProperty("project")] - public Project Project { get; set; } - - [JsonProperty("uri")] - public string Uri { get; set; } - - [JsonProperty("sourceBranch")] - public string SourceBranch { get; set; } - - [JsonProperty("sourceVersion")] - public string SourceVersion { get; set; } - - [JsonProperty("priority")] - public string Priority { get; set; } - - [JsonProperty("reason")] - public string Reason { get; set; } - - [JsonProperty("requestedFor")] - public LastChangedBy RequestedFor { get; set; } - - [JsonProperty("requestedBy")] - public LastChangedBy RequestedBy { get; set; } - - [JsonProperty("lastChangedDate")] - public DateTimeOffset LastChangedDate { get; set; } - - [JsonProperty("lastChangedBy")] - public LastChangedBy LastChangedBy { get; set; } - - [JsonProperty("orchestrationPlan")] - public Plan OrchestrationPlan { get; set; } - - [JsonProperty("logs")] - public Logs Logs { get; set; } - - [JsonProperty("repository")] - public Repository Repository { get; set; } - - [JsonProperty("keepForever")] - public bool KeepForever { get; set; } - - [JsonProperty("retainedByRelease")] - public bool RetainedByRelease { get; set; } - - [JsonProperty("triggeredByBuild")] - public object TriggeredByBuild { get; set; } -} - -public class Definition -{ - [JsonProperty("drafts")] - public List Drafts { get; set; } - - [JsonProperty("id")] - public long Id { get; set; } - - [JsonProperty("name")] - public string Name { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } - - [JsonProperty("uri")] - public string Uri { get; set; } - - [JsonProperty("path")] - public string Path { get; set; } - - [JsonProperty("type")] - public string Type { get; set; } - - [JsonProperty("queueStatus")] - public string QueueStatus { get; set; } - - [JsonProperty("revision")] - public long Revision { get; set; } - - [JsonProperty("project")] - public Project Project { get; set; } -} - -public class Project -{ - [JsonProperty("id")] - public Guid Id { get; set; } - - [JsonProperty("name")] - public string Name { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } - - [JsonProperty("state")] - public string State { get; set; } - - [JsonProperty("revision")] - public long Revision { get; set; } - - [JsonProperty("visibility")] - public string Visibility { get; set; } - - [JsonProperty("lastUpdateTime")] - public DateTimeOffset LastUpdateTime { get; set; } -} - -public class LastChangedBy -{ - [JsonProperty("displayName")] - public string DisplayName { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } - - [JsonProperty("_links")] - public LastChangedByLinks Links { get; set; } - - [JsonProperty("id")] - public Guid Id { get; set; } - - [JsonProperty("uniqueName")] - public object UniqueName { get; set; } - - [JsonProperty("imageUrl")] - public object ImageUrl { get; set; } - - [JsonProperty("descriptor")] - public string Descriptor { get; set; } -} - -public class LastChangedByLinks -{ - [JsonProperty("avatar")] - public Badge Avatar { get; set; } -} - -public class Badge -{ - [JsonProperty("href")] - public Uri Href { get; set; } -} - -public class BuildLinks -{ - [JsonProperty("self")] - public Badge Self { get; set; } - - [JsonProperty("web")] - public Badge Web { get; set; } - - [JsonProperty("sourceVersionDisplayUri")] - public Badge SourceVersionDisplayUri { get; set; } - - [JsonProperty("timeline")] - public Badge Timeline { get; set; } - - [JsonProperty("badge")] - public Badge Badge { get; set; } -} - -public class Logs -{ - [JsonProperty("id")] - public long Id { get; set; } - - [JsonProperty("type")] - public string Type { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } -} - -public class Plan -{ - [JsonProperty("planId")] - public Guid PlanId { get; set; } -} - -public class Properties -{ -} - -public class Repository -{ - [JsonProperty("id")] - public string Id { get; set; } - - [JsonProperty("type")] - public string Type { get; set; } - - [JsonProperty("clean")] - public object Clean { get; set; } - - [JsonProperty("checkoutSubmodules")] - public bool CheckoutSubmodules { get; set; } -} - -public class TriggerInfo -{ - [JsonProperty("ci.sourceBranch")] - public string CiSourceBranch { get; set; } - - [JsonProperty("ci.sourceSha")] - public string CiSourceSha { get; set; } - - [JsonProperty("ci.message")] - public string CiMessage { get; set; } - - [JsonProperty("ci.triggerRepository")] - public string CiTriggerRepository { get; set; } -} \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Models/GitHubDifference.cs b/src/Artemis.UI.Windows/Models/GitHubDifference.cs deleted file mode 100644 index 8a32d0d77..000000000 --- a/src/Artemis.UI.Windows/Models/GitHubDifference.cs +++ /dev/null @@ -1,243 +0,0 @@ -#nullable disable -using System; -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace Artemis.UI.Windows.Models; - -public class GitHubDifference -{ - [JsonProperty("url")] - public Uri Url { get; set; } - - [JsonProperty("html_url")] - public Uri HtmlUrl { get; set; } - - [JsonProperty("permalink_url")] - public Uri PermalinkUrl { get; set; } - - [JsonProperty("diff_url")] - public Uri DiffUrl { get; set; } - - [JsonProperty("patch_url")] - public Uri PatchUrl { get; set; } - - [JsonProperty("base_commit")] - public BaseCommitClass BaseCommit { get; set; } - - [JsonProperty("merge_base_commit")] - public BaseCommitClass MergeBaseCommit { get; set; } - - [JsonProperty("status")] - public string Status { get; set; } - - [JsonProperty("ahead_by")] - public long AheadBy { get; set; } - - [JsonProperty("behind_by")] - public long BehindBy { get; set; } - - [JsonProperty("total_commits")] - public long TotalCommits { get; set; } - - [JsonProperty("commits")] - public List Commits { get; set; } - - [JsonProperty("files")] - public List Files { get; set; } -} - -public class BaseCommitClass -{ - [JsonProperty("sha")] - public string Sha { get; set; } - - [JsonProperty("node_id")] - public string NodeId { get; set; } - - [JsonProperty("commit")] - public BaseCommitCommit Commit { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } - - [JsonProperty("html_url")] - public Uri HtmlUrl { get; set; } - - [JsonProperty("comments_url")] - public Uri CommentsUrl { get; set; } - - [JsonProperty("author")] - public BaseCommitAuthor Author { get; set; } - - [JsonProperty("committer")] - public BaseCommitAuthor Committer { get; set; } - - [JsonProperty("parents")] - public List Parents { get; set; } -} - -public class BaseCommitAuthor -{ - [JsonProperty("login")] - public string Login { get; set; } - - [JsonProperty("id")] - public long Id { get; set; } - - [JsonProperty("node_id")] - public string NodeId { get; set; } - - [JsonProperty("avatar_url")] - public Uri AvatarUrl { get; set; } - - [JsonProperty("gravatar_id")] - public string GravatarId { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } - - [JsonProperty("html_url")] - public Uri HtmlUrl { get; set; } - - [JsonProperty("followers_url")] - public Uri FollowersUrl { get; set; } - - [JsonProperty("following_url")] - public string FollowingUrl { get; set; } - - [JsonProperty("gists_url")] - public string GistsUrl { get; set; } - - [JsonProperty("starred_url")] - public string StarredUrl { get; set; } - - [JsonProperty("subscriptions_url")] - public Uri SubscriptionsUrl { get; set; } - - [JsonProperty("organizations_url")] - public Uri OrganizationsUrl { get; set; } - - [JsonProperty("repos_url")] - public Uri ReposUrl { get; set; } - - [JsonProperty("events_url")] - public string EventsUrl { get; set; } - - [JsonProperty("received_events_url")] - public Uri ReceivedEventsUrl { get; set; } - - [JsonProperty("type")] - public string Type { get; set; } - - [JsonProperty("site_admin")] - public bool SiteAdmin { get; set; } -} - -public class BaseCommitCommit -{ - [JsonProperty("author")] - public PurpleAuthor Author { get; set; } - - [JsonProperty("committer")] - public PurpleAuthor Committer { get; set; } - - [JsonProperty("message")] - public string Message { get; set; } - - [JsonProperty("tree")] - public Tree Tree { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } - - [JsonProperty("comment_count")] - public long CommentCount { get; set; } - - [JsonProperty("verification")] - public Verification Verification { get; set; } -} - -public class PurpleAuthor -{ - [JsonProperty("name")] - public string Name { get; set; } - - [JsonProperty("email")] - public string Email { get; set; } - - [JsonProperty("date")] - public DateTimeOffset Date { get; set; } -} - -public class Tree -{ - [JsonProperty("sha")] - public string Sha { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } -} - -public class Verification -{ - [JsonProperty("verified")] - public bool Verified { get; set; } - - [JsonProperty("reason")] - public string Reason { get; set; } - - [JsonProperty("signature")] - public string Signature { get; set; } - - [JsonProperty("payload")] - public string Payload { get; set; } -} - -public class Parent -{ - [JsonProperty("sha")] - public string Sha { get; set; } - - [JsonProperty("url")] - public Uri Url { get; set; } - - [JsonProperty("html_url")] - public Uri HtmlUrl { get; set; } -} - -public class File -{ - [JsonProperty("sha")] - public string Sha { get; set; } - - [JsonProperty("filename")] - public string Filename { get; set; } - - [JsonProperty("status")] - public string Status { get; set; } - - [JsonProperty("additions")] - public long Additions { get; set; } - - [JsonProperty("deletions")] - public long Deletions { get; set; } - - [JsonProperty("changes")] - public long Changes { get; set; } - - [JsonProperty("blob_url")] - public Uri BlobUrl { get; set; } - - [JsonProperty("raw_url")] - public Uri RawUrl { get; set; } - - [JsonProperty("contents_url")] - public Uri ContentsUrl { get; set; } - - [JsonProperty("patch")] - public string Patch { get; set; } - - [JsonProperty("previous_filename", NullValueHandling = NullValueHandling.Ignore)] - public string PreviousFilename { get; set; } -} \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Providers/AutoRunProvider.cs b/src/Artemis.UI.Windows/Providers/AutoRunProvider.cs index 40c2600a4..331352a9a 100644 --- a/src/Artemis.UI.Windows/Providers/AutoRunProvider.cs +++ b/src/Artemis.UI.Windows/Providers/AutoRunProvider.cs @@ -111,7 +111,7 @@ public class AutoRunProvider : IAutoRunProvider /// public async Task EnableAutoRun(bool recreate, int autoRunDelay) { - if (Constants.BuildInfo.IsLocalBuild) + if (Constants.CurrentVersion == "development") return; await CleanupOldAutorun(); 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.Windows/Providers/WindowsUpdateNotificationProvider.cs b/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs new file mode 100644 index 000000000..7af9d6927 --- /dev/null +++ b/src/Artemis.UI.Windows/Providers/WindowsUpdateNotificationProvider.cs @@ -0,0 +1,183 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Windows.UI.Notifications; +using Artemis.UI.Screens.Settings; +using Artemis.UI.Services.Updating; +using Artemis.UI.Shared.Services.MainWindow; +using Avalonia.Threading; +using DryIoc.ImTools; +using Microsoft.Toolkit.Uwp.Notifications; +using ReactiveUI; + +namespace Artemis.UI.Windows.Providers; + +public class WindowsUpdateNotificationProvider : IUpdateNotificationProvider +{ + private readonly Func _getReleaseInstaller; + private readonly Func _getSettingsViewModel; + private readonly IMainWindowService _mainWindowService; + private readonly IUpdateService _updateService; + private CancellationTokenSource? _cancellationTokenSource; + + public WindowsUpdateNotificationProvider(IMainWindowService mainWindowService, + IUpdateService updateService, + Func getSettingsViewModel, + Func getReleaseInstaller) + { + _mainWindowService = mainWindowService; + _updateService = updateService; + _getSettingsViewModel = getSettingsViewModel; + _getReleaseInstaller = getReleaseInstaller; + ToastNotificationManagerCompat.OnActivated += ToastNotificationManagerCompatOnOnActivated; + } + + /// + public void ShowNotification(Guid releaseId, string releaseVersion) + { + GetBuilderForRelease(releaseId, releaseVersion) + .AddText("Update available") + .AddText($"Artemis {releaseVersion} has been released") + .AddButton(new ToastButton() + .SetContent("Install") + .AddArgument("action", "install").SetAfterActivationBehavior(ToastAfterActivationBehavior.PendingUpdate)) + .AddButton(new ToastButton().SetContent("View changes").AddArgument("action", "view-changes")) + .Show(t => t.Tag = releaseId.ToString()); + } + + /// + public void ShowInstalledNotification(string installedVersion) + { + new ToastContentBuilder().AddArgument("releaseVersion", installedVersion) + .AddText("Update installed") + .AddText($"Artemis {installedVersion} has been installed") + .AddButton(new ToastButton().SetContent("View changes").AddArgument("action", "view-changes")) + .Show(); + } + + private void ViewRelease(string releaseVersion) + { + Dispatcher.UIThread.Post(() => + { + _mainWindowService.OpenMainWindow(); + if (_mainWindowService.HostScreen == null) + return; + + // TODO: When proper routing has been implemented, use that here + // Create a settings VM to navigate to + SettingsViewModel settingsViewModel = _getSettingsViewModel(_mainWindowService.HostScreen); + // Get the release tab + ReleasesTabViewModel releaseTabViewModel = (ReleasesTabViewModel) settingsViewModel.SettingTabs.First(t => t is ReleasesTabViewModel); + + // Navigate to the settings VM + _mainWindowService.HostScreen.Router.Navigate.Execute(settingsViewModel); + // Navigate to the release tab + releaseTabViewModel.PreselectVersion = releaseVersion; + settingsViewModel.SelectedTab = releaseTabViewModel; + }); + } + + private async Task InstallRelease(Guid releaseId, string releaseVersion) + { + ReleaseInstaller installer = _getReleaseInstaller(releaseId); + void InstallerOnPropertyChanged(object? sender, PropertyChangedEventArgs e) => UpdateInstallProgress(releaseId, installer); + + GetBuilderForRelease(releaseId, releaseVersion) + .AddAudio(new ToastAudio {Silent = true}) + .AddText("Installing Artemis update") + .AddVisualChild(new AdaptiveProgressBar() + { + Title = releaseVersion, + Value = new BindableProgressBarValue("progressValue"), + Status = new BindableString("progressStatus") + }) + .AddButton(new ToastButton().SetContent("Cancel").AddArgument("action", "cancel")) + .Show(t => + { + t.Tag = releaseId.ToString(); + t.Data = GetDataForInstaller(installer); + }); + + // Wait for Windows animations to catch up to us, we fast! + await Task.Delay(2000); + _cancellationTokenSource = new CancellationTokenSource(); + installer.PropertyChanged += InstallerOnPropertyChanged; + try + { + await installer.InstallAsync(_cancellationTokenSource.Token); + } + catch (Exception) + { + if (_cancellationTokenSource.IsCancellationRequested) + return; + throw; + } + finally + { + installer.PropertyChanged -= InstallerOnPropertyChanged; + } + + // If the main window is not open the user isn't busy, restart straight away + if (!_mainWindowService.IsMainWindowOpen) + { + _updateService.RestartForUpdate(true); + return; + } + + // Ask for a restart because the user is actively using Artemis + GetBuilderForRelease(releaseId, releaseVersion) + .AddAudio(new ToastAudio {Silent = true}) + .AddText("Update ready") + .AddText("Artemis must restart to finish the update") + .AddButton(new ToastButton().SetContent("Restart Artemis").AddArgument("action", "restart-for-update")) + .AddButton(new ToastButton().SetContent("Later").AddArgument("action", "postpone-update")) + .Show(t => t.Tag = releaseId.ToString()); + } + + private void UpdateInstallProgress(Guid releaseId, ReleaseInstaller installer) + { + ToastNotificationManagerCompat.CreateToastNotifier().Update(GetDataForInstaller(installer), releaseId.ToString()); + } + + private ToastContentBuilder GetBuilderForRelease(Guid releaseId, string releaseVersion) + { + return new ToastContentBuilder().AddArgument("releaseId", releaseId.ToString()).AddArgument("releaseVersion", releaseVersion); + } + + private NotificationData GetDataForInstaller(ReleaseInstaller installer) + { + NotificationData data = new() + { + Values = + { + ["progressValue"] = (installer.Progress / 100f).ToString(CultureInfo.InvariantCulture), + ["progressStatus"] = installer.Status + } + }; + + return data; + } + + private async void ToastNotificationManagerCompatOnOnActivated(ToastNotificationActivatedEventArgsCompat e) + { + ToastArguments args = ToastArguments.Parse(e.Argument); + + Guid releaseId = args.Contains("releaseId") ? Guid.Parse(args.Get("releaseId")) : Guid.Empty; + string releaseVersion = args.Get("releaseVersion"); + string action = "view-changes"; + if (args.Contains("action")) + action = args.Get("action"); + + if (action == "install") + await InstallRelease(releaseId, releaseVersion); + else if (action == "view-changes") + ViewRelease(releaseVersion); + else if (action == "cancel") + _cancellationTokenSource?.Cancel(); + else if (action == "restart-for-update") + _updateService.RestartForUpdate(false); + } +} \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Screens/Update/UpdateDialogView.axaml b/src/Artemis.UI.Windows/Screens/Update/UpdateDialogView.axaml deleted file mode 100644 index 481709577..000000000 --- a/src/Artemis.UI.Windows/Screens/Update/UpdateDialogView.axaml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - A new Artemis update is available! 🥳 - - - - Retrieving changes... - - - - - - - - - - - - - - - - - - - Changelog (auto-generated) - - - - - - - - - - - - - - - - - - We couldn't retrieve any changes - View online - - - - - - - - - - \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Screens/Update/UpdateDialogView.axaml.cs b/src/Artemis.UI.Windows/Screens/Update/UpdateDialogView.axaml.cs deleted file mode 100644 index 9c33b35b8..000000000 --- a/src/Artemis.UI.Windows/Screens/Update/UpdateDialogView.axaml.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Artemis.UI.Shared; -using Avalonia; -using Avalonia.Markup.Xaml; - -namespace Artemis.UI.Windows.Screens.Update; - -public class UpdateDialogView : ReactiveCoreWindow -{ - public UpdateDialogView() - { - InitializeComponent(); -#if DEBUG - this.AttachDevTools(); -#endif - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } -} \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Screens/Update/UpdateDialogViewModel.cs b/src/Artemis.UI.Windows/Screens/Update/UpdateDialogViewModel.cs deleted file mode 100644 index a19aecc6a..000000000 --- a/src/Artemis.UI.Windows/Screens/Update/UpdateDialogViewModel.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.Linq; -using System.Reactive; -using System.Reactive.Disposables; -using System.Threading.Tasks; -using Artemis.Core; -using Artemis.UI.Shared; -using Artemis.UI.Shared.Providers; -using Artemis.UI.Shared.Services; -using Artemis.UI.Shared.Services.Builders; -using Artemis.UI.Windows.Models; -using Artemis.UI.Windows.Providers; -using Avalonia.Threading; -using DynamicData; -using ReactiveUI; - -namespace Artemis.UI.Windows.Screens.Update; - -public class UpdateDialogViewModel : DialogViewModelBase -{ - // Based on https://docs.microsoft.com/en-us/azure/devops/pipelines/repos/github?view=azure-devops&tabs=yaml#skipping-ci-for-individual-commits - private readonly string[] _excludedCommitMessages = - { - "[skip ci]", - "[ci skip]", - "skip-checks: true", - "skip-checks:true", - "[skip azurepipelines]", - "[azurepipelines skip]", - "[skip azpipelines]", - "[azpipelines skip]", - "[skip azp]", - "[azp skip]", - "***NO_CI***" - }; - - private readonly INotificationService _notificationService; - private readonly UpdateProvider _updateProvider; - private bool _hasChanges; - private string? _latestBuild; - - private bool _retrievingChanges; - - public UpdateDialogViewModel(string channel, IUpdateProvider updateProvider, INotificationService notificationService) - { - _updateProvider = (UpdateProvider) updateProvider; - _notificationService = notificationService; - - Channel = channel; - CurrentBuild = Constants.BuildInfo.BuildNumberDisplay; - - this.WhenActivated((CompositeDisposable _) => Dispatcher.UIThread.InvokeAsync(GetBuildChanges)); - Install = ReactiveCommand.Create(() => Close(true)); - AskLater = ReactiveCommand.Create(() => Close(false)); - } - - public ReactiveCommand Install { get; } - public ReactiveCommand AskLater { get; } - - public string Channel { get; } - public string CurrentBuild { get; } - - public ObservableCollection Changes { get; } = new(); - - public bool RetrievingChanges - { - get => _retrievingChanges; - set => RaiseAndSetIfChanged(ref _retrievingChanges, value); - } - - public bool HasChanges - { - get => _hasChanges; - set => RaiseAndSetIfChanged(ref _hasChanges, value); - } - - public string? LatestBuild - { - get => _latestBuild; - set => RaiseAndSetIfChanged(ref _latestBuild, value); - } - - private async Task GetBuildChanges() - { - try - { - RetrievingChanges = true; - Task currentTask = _updateProvider.GetBuildInfo(1, CurrentBuild); - Task latestTask = _updateProvider.GetBuildInfo(1); - - DevOpsBuild? current = await currentTask; - DevOpsBuild? latest = await latestTask; - - LatestBuild = latest?.BuildNumber; - if (current != null && latest != null) - { - GitHubDifference difference = await _updateProvider.GetBuildDifferences(current, latest); - - // Only take commits with one parents (no merges) - Changes.Clear(); - Changes.AddRange(difference.Commits.Where(c => c.Parents.Count == 1) - .SelectMany(c => c.Commit.Message.Split("\n")) - .Select(m => m.Trim()) - .Where(m => !string.IsNullOrWhiteSpace(m) && !_excludedCommitMessages.Contains(m)) - .OrderBy(m => m) - ); - HasChanges = Changes.Any(); - } - } - catch (Exception e) - { - _notificationService.CreateNotification().WithTitle("Failed to retrieve build changes").WithMessage(e.Message).WithSeverity(NotificationSeverity.Error).Show(); - } - finally - { - RetrievingChanges = false; - } - } -} \ No newline at end of file diff --git a/src/Artemis.UI.Windows/Scripts/update.ps1 b/src/Artemis.UI.Windows/Scripts/update.ps1 new file mode 100644 index 000000000..607032109 --- /dev/null +++ b/src/Artemis.UI.Windows/Scripts/update.ps1 @@ -0,0 +1,56 @@ +param ( + [Parameter(Mandatory = $true)][string]$sourceDirectory, + [Parameter(Mandatory = $true)][string]$destinationDirectory, + [Parameter(Mandatory = $false)][string]$artemisArgs +) + +Write-Host "Artemis update script v1" + +# Wait up to 10 seconds for the process to shut down +for ($i = 1; $i -le 10; $i++) { + $process = Get-Process -Name Artemis.UI.Windows -ErrorAction SilentlyContinue + if (!$process) + { + break + } + Write-Host "Waiting for Artemis to shut down ($i / 10)" + Start-Sleep -Seconds 1 +} + +# If the process is still running, kill it +$process = Get-Process -Name Artemis.UI.Windows -ErrorAction SilentlyContinue +if ($process) +{ + Stop-Process -Id $process.Id -Force + Start-Sleep -Seconds 1 +} + +# Check if the destination directory exists +if (!(Test-Path $destinationDirectory)) +{ + Write-Error "The destination directory at $destinationDirectory does not exist" + Exit 1 +} + +# Clear the destination directory but don't remove it, leaving ACL entries in tact +Write-Host "Cleaning up old version where needed" +Get-ChildItem $destinationDirectory | Remove-Item -Recurse -Force + +# Move the contents of the source directory to the destination directory +Write-Host "Installing new files" +Get-ChildItem $sourceDirectory | Move-Item -Destination $destinationDirectory +# Remove the now empty source directory +Remove-Item $sourceDirectory + +Write-Host "Finished! Restarting Artemis" +Start-Sleep -Seconds 1 + +# When finished, run the updated version +if ($artemisArgs) +{ + Start-Process -FilePath "$destinationDirectory\Artemis.UI.Windows.exe" -WorkingDirectory $destinationDirectory -ArgumentList $artemisArgs +} +else +{ + Start-Process -FilePath "$destinationDirectory\Artemis.UI.Windows.exe" -WorkingDirectory $destinationDirectory +} \ No newline at end of file diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index 0773eb19f..e2619345d 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -11,6 +11,7 @@ + @@ -28,7 +29,9 @@ + + @@ -40,4 +43,15 @@ + + + + UpdatingTabView.axaml + Code + + + UpdatingTabView.axaml + Code + + \ No newline at end of file diff --git a/src/Artemis.UI/ArtemisBootstrapper.cs b/src/Artemis.UI/ArtemisBootstrapper.cs index 1b47e8748..c1dd988a9 100644 --- a/src/Artemis.UI/ArtemisBootstrapper.cs +++ b/src/Artemis.UI/ArtemisBootstrapper.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Reactive; -using System.Threading.Tasks; using Artemis.Core; using Artemis.Core.DryIoc; using Artemis.UI.DryIoc; @@ -12,6 +11,7 @@ using Artemis.UI.Shared.DataModelPicker; using Artemis.UI.Shared.DryIoc; using Artemis.UI.Shared.Services; using Artemis.VisualScripting.DryIoc; +using Artemis.WebClient.Updating.DryIoc; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -42,6 +42,7 @@ public static class ArtemisBootstrapper _container.RegisterCore(); _container.RegisterUI(); _container.RegisterSharedUI(); + _container.RegisterUpdatingClient(); _container.RegisterNoStringEvaluating(); configureServices?.Invoke(_container); diff --git a/src/Artemis.UI/DefaultTypes/PropertyInput/BrushPropertyInputView.axaml b/src/Artemis.UI/DefaultTypes/PropertyInput/BrushPropertyInputView.axaml index 8407e5714..183116c26 100644 --- a/src/Artemis.UI/DefaultTypes/PropertyInput/BrushPropertyInputView.axaml +++ b/src/Artemis.UI/DefaultTypes/PropertyInput/BrushPropertyInputView.axaml @@ -27,7 +27,8 @@ Width="200" VerticalAlignment="Center" Items="{CompiledBinding Descriptors}" - SelectedItem="{CompiledBinding SelectedDescriptor}"> + SelectedItem="{CompiledBinding SelectedDescriptor}" + PlaceholderText="Please select a brush"> diff --git a/src/Artemis.UI/DefaultTypes/PropertyInput/BrushPropertyInputViewModel.cs b/src/Artemis.UI/DefaultTypes/PropertyInput/BrushPropertyInputViewModel.cs index 26c548dca..8f9ae6277 100644 --- a/src/Artemis.UI/DefaultTypes/PropertyInput/BrushPropertyInputViewModel.cs +++ b/src/Artemis.UI/DefaultTypes/PropertyInput/BrushPropertyInputViewModel.cs @@ -59,7 +59,7 @@ public class BrushPropertyInputViewModel : PropertyInputViewModel protected override void ApplyInputValue() { - if (LayerProperty.ProfileElement is not Layer layer || layer.LayerBrush == null || SelectedDescriptor == null) + if (LayerProperty.ProfileElement is not Layer layer || SelectedDescriptor == null) return; _profileEditorService.ExecuteCommand(new ChangeLayerBrush(layer, SelectedDescriptor)); diff --git a/src/Artemis.UI/DryIoc/ContainerExtensions.cs b/src/Artemis.UI/DryIoc/ContainerExtensions.cs index 3e4caf226..9a4095b20 100644 --- a/src/Artemis.UI/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.UI/DryIoc/ContainerExtensions.cs @@ -4,6 +4,7 @@ using Artemis.UI.DryIoc.InstanceProviders; using Artemis.UI.Screens; using Artemis.UI.Screens.VisualScripting; using Artemis.UI.Services.Interfaces; +using Artemis.UI.Services.Updating; using Artemis.UI.Shared; using Artemis.UI.Shared.Services.NodeEditor; using Artemis.UI.Shared.Services.ProfileEditor; @@ -30,13 +31,13 @@ public static class ContainerExtensions container.Register(Reuse.Singleton); container.RegisterMany(thisAssembly, type => type.IsAssignableTo()); - container.RegisterMany(thisAssembly, type => type.IsAssignableTo(), ifAlreadyRegistered: IfAlreadyRegistered.Replace); container.RegisterMany(thisAssembly, type => type.IsAssignableTo() && type.IsInterface); container.RegisterMany(thisAssembly, type => type.IsAssignableTo() && type != typeof(PropertyVmFactory)); container.Register(Reuse.Singleton); container.Register(Reuse.Singleton); - + container.Register(); + container.RegisterMany(thisAssembly, type => type.IsAssignableTo(), Reuse.Singleton); } } \ No newline at end of file diff --git a/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs b/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs index ed71b2c6e..7d3e344aa 100644 --- a/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs +++ b/src/Artemis.UI/DryIoc/Factories/IVMFactory.cs @@ -1,4 +1,5 @@ -using System.Collections.ObjectModel; +using System; +using System.Collections.ObjectModel; using System.Reactive; using Artemis.Core; using Artemis.Core.LayerBrushes; @@ -17,6 +18,7 @@ using Artemis.UI.Screens.ProfileEditor.Properties.Tree; using Artemis.UI.Screens.ProfileEditor.VisualEditor.Visualizers; using Artemis.UI.Screens.Scripting; using Artemis.UI.Screens.Settings; +using Artemis.UI.Screens.Settings.Updating; using Artemis.UI.Screens.Sidebar; using Artemis.UI.Screens.SurfaceEditor; using Artemis.UI.Screens.VisualScripting; @@ -474,4 +476,23 @@ public class ScriptVmFactory : IScriptVmFactory { return _container.Resolve(new object[] { profile, scriptConfiguration }); } +} + +public interface IReleaseVmFactory : IVmFactory +{ + ReleaseViewModel ReleaseListViewModel(Guid releaseId, string version, DateTimeOffset createdAt); +} +public class ReleaseVmFactory : IReleaseVmFactory +{ + private readonly IContainer _container; + + public ReleaseVmFactory(IContainer container) + { + _container = container; + } + + public ReleaseViewModel ReleaseListViewModel(Guid releaseId, string version, DateTimeOffset createdAt) + { + return _container.Resolve(new object[] { releaseId, version, createdAt }); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Extensions/CompositeDisposableExtensions.cs b/src/Artemis.UI/Extensions/CompositeDisposableExtensions.cs new file mode 100644 index 000000000..743035d3e --- /dev/null +++ b/src/Artemis.UI/Extensions/CompositeDisposableExtensions.cs @@ -0,0 +1,14 @@ +using System.Reactive.Disposables; +using System.Threading; + +namespace Artemis.UI.Extensions; + +public static class CompositeDisposableExtensions +{ + public static CancellationToken AsCancellationToken(this CompositeDisposable disposable) + { + CancellationTokenSource tokenSource = new(); + Disposable.Create(tokenSource, s => s.Cancel()).DisposeWith(disposable); + return tokenSource.Token; + } +} \ No newline at end of file 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/Extensions/ZipArchiveExtensions.cs b/src/Artemis.UI/Extensions/ZipArchiveExtensions.cs new file mode 100644 index 000000000..0d1fc507d --- /dev/null +++ b/src/Artemis.UI/Extensions/ZipArchiveExtensions.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; + +namespace Artemis.UI.Extensions; + +// Taken from System.IO.Compression with progress reporting slapped on top +public static class ZipArchiveExtensions +{ + /// + /// Extracts all the files in the zip archive to a directory on the file system. + /// + /// The zip archive to extract files from. + /// The path to the directory to place the extracted files in. You can specify either a relative or an absolute path. A relative path is interpreted as relative to the current working directory. + /// A boolean indicating whether to override existing files + /// The progress to report to. + /// A cancellation token + public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, IProgress progress, CancellationToken cancellationToken) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + if (destinationDirectoryName == null) + throw new ArgumentNullException(nameof(destinationDirectoryName)); + + for (int index = 0; index < source.Entries.Count; index++) + { + ZipArchiveEntry entry = source.Entries[index]; + entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles); + progress.Report((index + 1f) / source.Entries.Count * 100f); + cancellationToken.ThrowIfCancellationRequested(); + } + } + + private static void ExtractRelativeToDirectory(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + if (destinationDirectoryName == null) + throw new ArgumentNullException(nameof(destinationDirectoryName)); + + // Note that this will give us a good DirectoryInfo even if destinationDirectoryName exists: + DirectoryInfo di = Directory.CreateDirectory(destinationDirectoryName); + string destinationDirectoryFullPath = di.FullName; + if (!destinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar)) + destinationDirectoryFullPath += Path.DirectorySeparatorChar; + + string fileDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, source.FullName)); + + if (!fileDestinationPath.StartsWith(destinationDirectoryFullPath, StringComparison)) + throw new IOException($"The file '{fileDestinationPath}' already exists."); + + if (Path.GetFileName(fileDestinationPath).Length == 0) + { + // If it is a directory: + + if (source.Length != 0) + throw new IOException("Extracting Zip entry would have resulted in a file outside the specified destination directory."); + + Directory.CreateDirectory(fileDestinationPath); + } + else + { + // If it is a file: + // Create containing directory: + Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); + source.ExtractToFile(fileDestinationPath, overwrite: overwrite); + } + } + private static StringComparison StringComparison => IsCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + private static bool IsCaseSensitive => !(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() || OperatingSystem.IsIOS() || OperatingSystem.IsTvOS() || OperatingSystem.IsWatchOS()); +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Debugger/Tabs/Logs/LogsDebugView.axaml.cs b/src/Artemis.UI/Screens/Debugger/Tabs/Logs/LogsDebugView.axaml.cs index 132d4606a..9922665fc 100644 --- a/src/Artemis.UI/Screens/Debugger/Tabs/Logs/LogsDebugView.axaml.cs +++ b/src/Artemis.UI/Screens/Debugger/Tabs/Logs/LogsDebugView.axaml.cs @@ -49,7 +49,7 @@ public class LogsDebugView : ReactiveUserControl //we need this help distance because of rounding. //if we scroll slightly above the end, we still want it //to scroll down to the new lines. - const double graceDistance = 1d; + const double GRACE_DISTANCE = 1d; //if we were at the bottom of the log and //if the last log event was 5 lines long @@ -59,7 +59,7 @@ public class LogsDebugView : ReactiveUserControl //if we are more than that out of sync, //the user scrolled up and we should not //mess with anything. - if (_lineCount == 0 || linesAdded + graceDistance > outOfScreenLines) + if (_lineCount == 0 || linesAdded + GRACE_DISTANCE > outOfScreenLines) { Dispatcher.UIThread.Post(() => _textEditor.ScrollToEnd(), DispatcherPriority.ApplicationIdle); _lineCount = _textEditor.LineCount; diff --git a/src/Artemis.UI/Screens/Home/HomeView.axaml b/src/Artemis.UI/Screens/Home/HomeView.axaml index a20f0df65..691770a13 100644 --- a/src/Artemis.UI/Screens/Home/HomeView.axaml +++ b/src/Artemis.UI/Screens/Home/HomeView.axaml @@ -109,7 +109,7 @@ + NavigateUri="https://wiki.artemis-rgb.com/en/donating"> Donate diff --git a/src/Artemis.UI/Screens/Root/RootViewModel.cs b/src/Artemis.UI/Screens/Root/RootViewModel.cs index b2ba9e8cd..d8abba43a 100644 --- a/src/Artemis.UI/Screens/Root/RootViewModel.cs +++ b/src/Artemis.UI/Screens/Root/RootViewModel.cs @@ -7,6 +7,7 @@ using Artemis.UI.DryIoc.Factories; using Artemis.UI.Models; using Artemis.UI.Screens.Sidebar; using Artemis.UI.Services.Interfaces; +using Artemis.UI.Services.Updating; using Artemis.UI.Shared; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.MainWindow; @@ -45,7 +46,7 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi { Router = new RoutingState(); WindowSizeSetting = settingsService.GetSetting("WindowSize"); - + _coreService = coreService; _settingsService = settingsService; _windowService = windowService; @@ -55,13 +56,17 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi _defaultTitleBarViewModel = defaultTitleBarViewModel; _sidebarVmFactory = sidebarVmFactory; _lifeTime = (IClassicDesktopStyleApplicationLifetime) Application.Current!.ApplicationLifetime!; - + mainWindowService.ConfigureMainWindowProvider(this); + mainWindowService.HostScreen = this; DisplayAccordingToSettings(); Router.CurrentViewModel.Subscribe(UpdateTitleBarViewModel); Task.Run(() => { + if (_updateService.Initialize()) + return; + coreService.Initialize(); registrationService.RegisterBuiltInDataModelDisplays(); registrationService.RegisterBuiltInDataModelInputs(); @@ -105,14 +110,10 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi bool showOnAutoRun = _settingsService.GetSetting("UI.ShowOnStartup", true).Value; if ((autoRunning && !showOnAutoRun) || minimized) - { - // TODO: Auto-update - } - else - { - ShowSplashScreen(); - _coreService.Initialized += (_, _) => Dispatcher.UIThread.InvokeAsync(OpenMainWindow); - } + return; + + ShowSplashScreen(); + _coreService.Initialized += (_, _) => Dispatcher.UIThread.InvokeAsync(OpenMainWindow); } private void ShowSplashScreen() @@ -229,11 +230,6 @@ public class RootViewModel : ActivatableViewModelBase, IScreen, IMainWindowProvi } #endregion - - public void SaveWindowBounds(int x, int y, int width, int height) - { - throw new NotImplementedException(); - } } internal class EmptyViewModel : MainScreenViewModel diff --git a/src/Artemis.UI/Screens/Settings/SettingsView.axaml b/src/Artemis.UI/Screens/Settings/SettingsView.axaml index 96433edb6..78ac70be4 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsView.axaml +++ b/src/Artemis.UI/Screens/Settings/SettingsView.axaml @@ -2,10 +2,12 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:settings="clr-namespace:Artemis.UI.Screens.Settings" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="Artemis.UI.Screens.Settings.SettingsView"> + x:Class="Artemis.UI.Screens.Settings.SettingsView" + x:DataType="settings:SettingsViewModel"> - + diff --git a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs index 0a372c29b..745ed277a 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs @@ -6,10 +6,13 @@ namespace Artemis.UI.Screens.Settings; public class SettingsViewModel : MainScreenViewModel { + private ActivatableViewModelBase _selectedTab; + public SettingsViewModel(IScreen hostScreen, GeneralTabViewModel generalTabViewModel, PluginsTabViewModel pluginsTabViewModel, DevicesTabViewModel devicesTabViewModel, + ReleasesTabViewModel releasesTabViewModel, AboutTabViewModel aboutTabViewModel) : base(hostScreen, "settings") { SettingTabs = new ObservableCollection @@ -17,9 +20,17 @@ public class SettingsViewModel : MainScreenViewModel generalTabViewModel, pluginsTabViewModel, devicesTabViewModel, + releasesTabViewModel, aboutTabViewModel }; + _selectedTab = generalTabViewModel; } public ObservableCollection SettingTabs { get; } + + public ActivatableViewModelBase SelectedTab + { + get => _selectedTab; + set => RaiseAndSetIfChanged(ref _selectedTab, value); + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Tabs/AboutTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/AboutTabViewModel.cs index 0ce8312c7..8fa53983e 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/AboutTabViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Tabs/AboutTabViewModel.cs @@ -57,7 +57,7 @@ public class AboutTabViewModel : ActivatableViewModelBase private async Task Activate() { AssemblyInformationalVersionAttribute? versionAttribute = typeof(AboutTabViewModel).Assembly.GetCustomAttribute(); - Version = $"Version {versionAttribute?.InformationalVersion} build {Constants.BuildInfo.BuildNumberDisplay}"; + Version = $"Version {Constants.CurrentVersion}"; try { diff --git a/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabView.axaml b/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabView.axaml index d8cd0ff65..953f0ac1c 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabView.axaml +++ b/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabView.axaml @@ -137,7 +137,7 @@ - + Updating diff --git a/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabViewModel.cs index 06d212fa1..5121af0f5 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Tabs/GeneralTabViewModel.cs @@ -12,9 +12,11 @@ using Artemis.Core.Providers; using Artemis.Core.Services; using Artemis.UI.Screens.StartupWizard; using Artemis.UI.Services.Interfaces; +using Artemis.UI.Services.Updating; using Artemis.UI.Shared; using Artemis.UI.Shared.Providers; using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; using Avalonia.Threading; using DryIoc; using DynamicData; @@ -30,6 +32,7 @@ public class GeneralTabViewModel : ActivatableViewModelBase private readonly PluginSetting _defaultLayerBrushDescriptor; private readonly ISettingsService _settingsService; private readonly IUpdateService _updateService; + private readonly INotificationService _notificationService; private readonly IWindowService _windowService; private bool _startupWizardOpen; @@ -38,13 +41,15 @@ public class GeneralTabViewModel : ActivatableViewModelBase IPluginManagementService pluginManagementService, IDebugService debugService, IWindowService windowService, - IUpdateService updateService) + IUpdateService updateService, + INotificationService notificationService) { DisplayName = "General"; _settingsService = settingsService; _debugService = debugService; _windowService = windowService; _updateService = updateService; + _notificationService = notificationService; _autoRunProvider = container.Resolve(IfUnresolved.ReturnDefault); List layerBrushProviders = pluginManagementService.GetFeaturesOfType(); @@ -88,7 +93,6 @@ public class GeneralTabViewModel : ActivatableViewModelBase public ReactiveCommand ShowDataFolder { get; } public bool IsAutoRunSupported => _autoRunProvider != null; - public bool IsUpdatingSupported => _updateService.UpdatingSupported; public ObservableCollection LayerBrushDescriptors { get; } public ObservableCollection GraphicsContexts { get; } @@ -142,8 +146,8 @@ public class GeneralTabViewModel : ActivatableViewModelBase public PluginSetting UIAutoRun => _settingsService.GetSetting("UI.AutoRun", false); public PluginSetting UIAutoRunDelay => _settingsService.GetSetting("UI.AutoRunDelay", 15); public PluginSetting UIShowOnStartup => _settingsService.GetSetting("UI.ShowOnStartup", true); - public PluginSetting UICheckForUpdates => _settingsService.GetSetting("UI.CheckForUpdates", true); - public PluginSetting UIAutoUpdate => _settingsService.GetSetting("UI.AutoUpdate", false); + public PluginSetting UICheckForUpdates => _settingsService.GetSetting("UI.Updating.AutoCheck", true); + public PluginSetting UIAutoUpdate => _settingsService.GetSetting("UI.Updating.AutoInstall", true); public PluginSetting ProfileEditorShowDataModelValues => _settingsService.GetSetting("ProfileEditor.ShowDataModelValues", false); public PluginSetting CoreLoggingLevel => _settingsService.GetSetting("Core.LoggingLevel", LogEventLevel.Information); public PluginSetting CorePreferredGraphicsContext => _settingsService.GetSetting("Core.PreferredGraphicsContext", "Software"); @@ -159,7 +163,25 @@ public class GeneralTabViewModel : ActivatableViewModelBase private async Task ExecuteCheckForUpdate(CancellationToken cancellationToken) { - await _updateService.ManualUpdate(); + try + { + // If an update was available a popup was shown, no need to continue + if (await _updateService.CheckForUpdate()) + return; + + _notificationService.CreateNotification() + .WithTitle("No update available") + .WithMessage("You are running the latest version in your current channel") + .Show(); + } + catch (Exception e) + { + _notificationService.CreateNotification() + .WithTitle("Failed to check for update") + .WithMessage(e.Message) + .WithSeverity(NotificationSeverity.Warning) + .Show(); + } } private async Task ExecuteShowSetupWizard() diff --git a/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabView.axaml b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabView.axaml new file mode 100644 index 000000000..aee70e179 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabView.axaml @@ -0,0 +1,65 @@ + + + + + + + Loading releases... + + + + + + + + Learn more about channels on the wiki + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabView.axaml.cs b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabView.axaml.cs new file mode 100644 index 000000000..3421db5a7 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabView.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Settings; + +public class ReleasesTabView : ReactiveUserControl +{ + public ReleasesTabView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabViewModel.cs new file mode 100644 index 000000000..de7f7cdd5 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabViewModel.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.DryIoc.Factories; +using Artemis.UI.Extensions; +using Artemis.UI.Screens.Settings.Updating; +using Artemis.UI.Services.Updating; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Artemis.WebClient.Updating; +using Avalonia.Threading; +using DynamicData; +using DynamicData.Binding; +using ReactiveUI; +using Serilog; +using StrawberryShake; + +namespace Artemis.UI.Screens.Settings; + +public class ReleasesTabViewModel : ActivatableViewModelBase +{ + private readonly ILogger _logger; + private readonly IUpdateService _updateService; + private readonly IUpdatingClient _updatingClient; + private readonly INotificationService _notificationService; + private readonly SourceList _releases; + private IGetReleases_PublishedReleases_PageInfo? _lastPageInfo; + private bool _loading; + private ReleaseViewModel? _selectedReleaseViewModel; + + public ReleasesTabViewModel(ILogger logger, IUpdateService updateService, IUpdatingClient updatingClient, IReleaseVmFactory releaseVmFactory, INotificationService notificationService) + { + _logger = logger; + _updateService = updateService; + _updatingClient = updatingClient; + _notificationService = notificationService; + + _releases = new SourceList(); + _releases.Connect() + .Sort(SortExpressionComparer.Descending(p => p.CreatedAt)) + .Transform(r => releaseVmFactory.ReleaseListViewModel(r.Id, r.Version, r.CreatedAt)) + .ObserveOn(AvaloniaScheduler.Instance) + .Bind(out ReadOnlyObservableCollection releaseViewModels) + .Subscribe(); + + DisplayName = "Releases"; + ReleaseViewModels = releaseViewModels; + Channel = _updateService.Channel; + this.WhenActivated(async d => + { + await _updateService.CacheLatestRelease(); + await GetMoreReleases(d.AsCancellationToken()); + SelectedReleaseViewModel = ReleaseViewModels.FirstOrDefault(r => r.Version == PreselectVersion) ?? ReleaseViewModels.FirstOrDefault(); + }); + } + + + public ReadOnlyObservableCollection ReleaseViewModels { get; } + public string Channel { get; } + public string? PreselectVersion { get; set; } + + public ReleaseViewModel? SelectedReleaseViewModel + { + get => _selectedReleaseViewModel; + set => RaiseAndSetIfChanged(ref _selectedReleaseViewModel, value); + } + + public bool Loading + { + get => _loading; + private set => RaiseAndSetIfChanged(ref _loading, value); + } + + public async Task GetMoreReleases(CancellationToken cancellationToken) + { + if (_lastPageInfo != null && !_lastPageInfo.HasNextPage) + return; + + try + { + Loading = true; + + IOperationResult result = await _updatingClient.GetReleases.ExecuteAsync(_updateService.Channel, Platform.Windows, 20, _lastPageInfo?.EndCursor, cancellationToken); + if (result.Data?.PublishedReleases?.Nodes == null) + return; + + _lastPageInfo = result.Data.PublishedReleases.PageInfo; + _releases.AddRange(result.Data.PublishedReleases.Nodes); + } + catch (TaskCanceledException) + { + // ignored + } + catch (Exception e) + { + _logger.Warning(e, "Failed to retrieve releases"); + _notificationService.CreateNotification() + .WithTitle("Failed to retrieve releases") + .WithMessage(e.Message) + .WithSeverity(NotificationSeverity.Warning) + .Show(); + } + finally + { + Loading = false; + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml new file mode 100644 index 000000000..b1f10bdab --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + Release info + + + + + + + + + + + + + + + + + + Ready, restart to install + + + + + + + + + + + + Release date + + + + + + Source + + + + + + File size + + + + + + + + + + Release notes + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs new file mode 100644 index 000000000..6bdcd52e7 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Settings.Updating; + +public class ReleaseView : ReactiveUserControl +{ + public ReleaseView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void InputElement_OnPointerReleased(object? sender, PointerReleasedEventArgs e) + { + ViewModel?.NavigateToSource(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseViewModel.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseViewModel.cs new file mode 100644 index 000000000..bb640c1d3 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseViewModel.cs @@ -0,0 +1,220 @@ +using System; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.UI.Extensions; +using Artemis.UI.Services.Updating; +using Artemis.UI.Shared; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Artemis.WebClient.Updating; +using ReactiveUI; +using Serilog; +using StrawberryShake; + +namespace Artemis.UI.Screens.Settings.Updating; + +public class ReleaseViewModel : ActivatableViewModelBase +{ + private readonly ILogger _logger; + private readonly INotificationService _notificationService; + private readonly IUpdateService _updateService; + private readonly Platform _updatePlatform; + private readonly IUpdatingClient _updatingClient; + private CancellationTokenSource? _installerCts; + private string? _changelog; + private string? _commit; + private string? _shortCommit; + private long _fileSize; + private bool _installationAvailable; + private bool _installationFinished; + private bool _installationInProgress; + private bool _loading = true; + private bool _retrievedDetails; + + public ReleaseViewModel(Guid releaseId, + string version, + DateTimeOffset createdAt, + ILogger logger, + IUpdatingClient updatingClient, + INotificationService notificationService, + IUpdateService updateService) + { + _logger = logger; + _updatingClient = updatingClient; + _notificationService = notificationService; + _updateService = updateService; + + if (OperatingSystem.IsWindows()) + _updatePlatform = Platform.Windows; + else if (OperatingSystem.IsLinux()) + _updatePlatform = Platform.Linux; + else if (OperatingSystem.IsMacOS()) + _updatePlatform = Platform.Osx; + else + throw new PlatformNotSupportedException("Cannot auto update on the current platform"); + + + ReleaseId = releaseId; + Version = version; + CreatedAt = createdAt; + ReleaseInstaller = updateService.GetReleaseInstaller(ReleaseId); + + Install = ReactiveCommand.CreateFromTask(ExecuteInstall); + Restart = ReactiveCommand.Create(ExecuteRestart); + CancelInstall = ReactiveCommand.Create(() => _installerCts?.Cancel()); + + this.WhenActivated(d => + { + // There's no point in running anything but the latest version of the current channel. + // Perhaps later that won't be true anymore, then we could consider allowing to install + // older versions with compatible database versions. + InstallationAvailable = _updateService.CachedLatestRelease?.Id == ReleaseId; + RetrieveDetails(d.AsCancellationToken()).ToObservable(); + Disposable.Create(_installerCts, cts => cts?.Cancel()).DisposeWith(d); + }); + } + + public Guid ReleaseId { get; } + + private void ExecuteRestart() + { + _updateService.RestartForUpdate(false); + } + + public ReactiveCommand Restart { get; set; } + public ReactiveCommand Install { get; } + public ReactiveCommand CancelInstall { get; } + + public string Version { get; } + public DateTimeOffset CreatedAt { get; } + public ReleaseInstaller ReleaseInstaller { get; } + + public string? Changelog + { + get => _changelog; + set => RaiseAndSetIfChanged(ref _changelog, value); + } + + public string? Commit + { + get => _commit; + set => RaiseAndSetIfChanged(ref _commit, value); + } + + public string? ShortCommit + { + get => _shortCommit; + set => RaiseAndSetIfChanged(ref _shortCommit, value); + } + + public long FileSize + { + get => _fileSize; + set => RaiseAndSetIfChanged(ref _fileSize, value); + } + + public bool Loading + { + get => _loading; + private set => RaiseAndSetIfChanged(ref _loading, value); + } + + public bool InstallationAvailable + { + get => _installationAvailable; + set => RaiseAndSetIfChanged(ref _installationAvailable, value); + } + + public bool InstallationInProgress + { + get => _installationInProgress; + set => RaiseAndSetIfChanged(ref _installationInProgress, value); + } + + public bool InstallationFinished + { + get => _installationFinished; + set => RaiseAndSetIfChanged(ref _installationFinished, value); + } + + public bool IsCurrentVersion => Version == Constants.CurrentVersion; + public bool IsPreviousVersion => Version == _updateService.PreviousVersion; + public bool ShowStatusIndicator => IsCurrentVersion || IsPreviousVersion; + + public void NavigateToSource() + { + Utilities.OpenUrl($"https://github.com/Artemis-RGB/Artemis/commit/{Commit}"); + } + + private async Task ExecuteInstall(CancellationToken cancellationToken) + { + _installerCts = new CancellationTokenSource(); + try + { + InstallationInProgress = true; + await ReleaseInstaller.InstallAsync(_installerCts.Token); + InstallationFinished = true; + } + catch (Exception e) + { + if (_installerCts.IsCancellationRequested) + return; + + _logger.Warning(e, "Failed to install update through UI"); + _notificationService.CreateNotification() + .WithTitle("Failed to install update") + .WithMessage(e.Message) + .WithSeverity(NotificationSeverity.Warning) + .Show(); + } + finally + { + InstallationInProgress = false; + } + } + + private async Task RetrieveDetails(CancellationToken cancellationToken) + { + if (_retrievedDetails) + return; + + try + { + Loading = true; + + IOperationResult result = await _updatingClient.GetReleaseById.ExecuteAsync(ReleaseId, cancellationToken); + IGetReleaseById_PublishedRelease? release = result.Data?.PublishedRelease; + if (release == null) + return; + + Changelog = release.Changelog; + Commit = release.Commit; + ShortCommit = release.Commit.Substring(0, 7); + FileSize = release.Artifacts.FirstOrDefault(a => a.Platform == _updatePlatform)?.FileInfo.DownloadSize ?? 0; + + _retrievedDetails = true; + } + catch (TaskCanceledException) + { + // ignored + } + catch (Exception e) + { + _logger.Warning(e, "Failed to retrieve release details"); + _notificationService.CreateNotification() + .WithTitle("Failed to retrieve details") + .WithMessage(e.Message) + .WithSeverity(NotificationSeverity.Warning) + .Show(); + } + finally + { + Loading = false; + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs index 4c3b971ad..7e1756384 100644 --- a/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs +++ b/src/Artemis.UI/Screens/Sidebar/SidebarViewModel.cs @@ -137,6 +137,10 @@ public class SidebarViewModel : ActivatableViewModelBase private void NavigateToScreen(SidebarScreenViewModel sidebarScreenViewModel) { + // If the current screen changed through external means and already matches, don't navigate again + if (_hostScreen.Router.GetCurrentViewModel()?.GetType() == sidebarScreenViewModel.ScreenType) + return; + _hostScreen.Router.Navigate.Execute(sidebarScreenViewModel.CreateInstance(_container, _hostScreen)); _profileEditorService.ChangeCurrentProfileConfiguration(null); } diff --git a/src/Artemis.UI/Screens/StartupWizard/StartupWizardViewModel.cs b/src/Artemis.UI/Screens/StartupWizard/StartupWizardViewModel.cs index 72d296643..99d0eb719 100644 --- a/src/Artemis.UI/Screens/StartupWizard/StartupWizardViewModel.cs +++ b/src/Artemis.UI/Screens/StartupWizard/StartupWizardViewModel.cs @@ -11,6 +11,7 @@ using Artemis.Core.Services; using Artemis.UI.DryIoc.Factories; using Artemis.UI.Screens.Plugins; using Artemis.UI.Services.Interfaces; +using Artemis.UI.Services.Updating; using Artemis.UI.Shared; using Artemis.UI.Shared.Providers; using Artemis.UI.Shared.Services; @@ -24,29 +25,29 @@ public class StartupWizardViewModel : DialogViewModelBase private readonly IAutoRunProvider? _autoRunProvider; private readonly IRgbService _rgbService; private readonly ISettingsService _settingsService; - private readonly IUpdateService _updateService; private readonly IWindowService _windowService; private int _currentStep; private bool _showContinue; private bool _showFinish; private bool _showGoBack; - public StartupWizardViewModel(IContainer container, ISettingsService settingsService, IRgbService rgbService, IPluginManagementService pluginManagementService, IWindowService windowService, - IUpdateService updateService, ISettingsVmFactory settingsVmFactory) + public StartupWizardViewModel(IContainer container, + ISettingsService settingsService, + IRgbService rgbService, + IPluginManagementService pluginManagementService, + IWindowService windowService, + ISettingsVmFactory settingsVmFactory) { _settingsService = settingsService; _rgbService = rgbService; _windowService = windowService; - _updateService = updateService; _autoRunProvider = container.Resolve(IfUnresolved.ReturnDefault); Continue = ReactiveCommand.Create(ExecuteContinue); GoBack = ReactiveCommand.Create(ExecuteGoBack); SkipOrFinishWizard = ReactiveCommand.Create(ExecuteSkipOrFinishWizard); SelectLayout = ReactiveCommand.Create(ExecuteSelectLayout); - - AssemblyInformationalVersionAttribute? versionAttribute = typeof(StartupWizardViewModel).Assembly.GetCustomAttribute(); - Version = $"Version {versionAttribute?.InformationalVersion} build {Constants.BuildInfo.BuildNumberDisplay}"; + Version = $"Version {Constants.CurrentVersion}"; // Take all compatible plugins that have an always-enabled device provider DeviceProviders = new ObservableCollection(pluginManagementService.GetAllPlugins() @@ -81,13 +82,12 @@ public class StartupWizardViewModel : DialogViewModelBase public ObservableCollection DeviceProviders { get; } public bool IsAutoRunSupported => _autoRunProvider != null; - public bool IsUpdatingSupported => _updateService.UpdatingSupported; public PluginSetting UIAutoRun => _settingsService.GetSetting("UI.AutoRun", false); public PluginSetting UIAutoRunDelay => _settingsService.GetSetting("UI.AutoRunDelay", 15); public PluginSetting UIShowOnStartup => _settingsService.GetSetting("UI.ShowOnStartup", true); - public PluginSetting UICheckForUpdates => _settingsService.GetSetting("UI.CheckForUpdates", true); - public PluginSetting UIAutoUpdate => _settingsService.GetSetting("UI.AutoUpdate", false); + public PluginSetting UICheckForUpdates => _settingsService.GetSetting("UI.Updating.AutoCheck", true); + public PluginSetting UIAutoUpdate => _settingsService.GetSetting("UI.Updating.AutoInstall", true); public int CurrentStep { @@ -119,7 +119,7 @@ public class StartupWizardViewModel : DialogViewModelBase CurrentStep--; // Skip the settings step if none of it's contents are supported - if (CurrentStep == 4 && !IsAutoRunSupported && !IsUpdatingSupported) + if (CurrentStep == 4 && !IsAutoRunSupported) CurrentStep--; SetupButtons(); @@ -131,7 +131,7 @@ public class StartupWizardViewModel : DialogViewModelBase CurrentStep++; // Skip the settings step if none of it's contents are supported - if (CurrentStep == 4 && !IsAutoRunSupported && !IsUpdatingSupported) + if (CurrentStep == 4 && !IsAutoRunSupported) CurrentStep++; SetupButtons(); diff --git a/src/Artemis.UI/Screens/StartupWizard/Steps/SettingsStep.axaml b/src/Artemis.UI/Screens/StartupWizard/Steps/SettingsStep.axaml index 294f59e91..bf68bf234 100644 --- a/src/Artemis.UI/Screens/StartupWizard/Steps/SettingsStep.axaml +++ b/src/Artemis.UI/Screens/StartupWizard/Steps/SettingsStep.axaml @@ -68,7 +68,7 @@ - + Updating diff --git a/src/Artemis.UI/Services/Interfaces/IUpdateService.cs b/src/Artemis.UI/Services/Interfaces/IUpdateService.cs deleted file mode 100644 index cc6236853..000000000 --- a/src/Artemis.UI/Services/Interfaces/IUpdateService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Threading.Tasks; - -namespace Artemis.UI.Services.Interfaces; - -public interface IUpdateService : IArtemisUIService -{ - /// - /// Gets a boolean indicating whether updating is supported. - /// - bool UpdatingSupported { get; } - - /// - /// Gets or sets a boolean indicating whether auto-updating is suspended. - /// - bool SuspendAutoUpdate { get; set; } - - /// - /// Manually checks for updates and offers to install it if found. - /// - /// Whether an update was found, regardless of whether the user chose to install it. - Task ManualUpdate(); -} \ 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/BasicUpdateNotificationProvider.cs b/src/Artemis.UI/Services/Updating/BasicUpdateNotificationProvider.cs new file mode 100644 index 000000000..6d0335ad7 --- /dev/null +++ b/src/Artemis.UI/Services/Updating/BasicUpdateNotificationProvider.cs @@ -0,0 +1,88 @@ +using System; +using System.Linq; +using Artemis.UI.Screens.Settings; +using Artemis.UI.Shared.Services; +using Artemis.UI.Shared.Services.Builders; +using Artemis.UI.Shared.Services.MainWindow; +using ReactiveUI; + +namespace Artemis.UI.Services.Updating; + +public class BasicUpdateNotificationProvider : IUpdateNotificationProvider +{ + private readonly Func _getSettingsViewModel; + private readonly IMainWindowService _mainWindowService; + private readonly INotificationService _notificationService; + private Action? _available; + private Action? _installed; + + public BasicUpdateNotificationProvider(INotificationService notificationService, IMainWindowService mainWindowService, Func getSettingsViewModel) + { + _notificationService = notificationService; + _mainWindowService = mainWindowService; + _getSettingsViewModel = getSettingsViewModel; + } + + /// + public void ShowNotification(Guid releaseId, string releaseVersion) + { + if (_mainWindowService.IsMainWindowOpen) + ShowAvailable(releaseVersion); + else + _mainWindowService.MainWindowOpened += (_, _) => ShowAvailable(releaseVersion); + } + + /// + public void ShowInstalledNotification(string installedVersion) + { + if (_mainWindowService.IsMainWindowOpen) + ShowInstalled(installedVersion); + else + _mainWindowService.MainWindowOpened += (_, _) => ShowInstalled(installedVersion); + } + + private void ShowAvailable(string releaseVersion) + { + _available?.Invoke(); + _available = _notificationService.CreateNotification() + .WithTitle("Update available") + .WithMessage($"Artemis {releaseVersion} has been released") + .WithSeverity(NotificationSeverity.Success) + .WithTimeout(TimeSpan.FromSeconds(15)) + .HavingButton(b => b.WithText("View release").WithAction(() => ViewRelease(releaseVersion))) + .Show(); + } + + private void ShowInstalled(string installedVersion) + { + _installed?.Invoke(); + _installed = _notificationService.CreateNotification() + .WithTitle("Update installed") + .WithMessage($"Artemis {installedVersion} has been installed.") + .WithSeverity(NotificationSeverity.Success) + .WithTimeout(TimeSpan.FromSeconds(15)) + .HavingButton(b => b.WithText("View release").WithAction(() => ViewRelease(installedVersion))) + .Show(); + } + + private void ViewRelease(string version) + { + _installed?.Invoke(); + _available?.Invoke(); + + if (_mainWindowService.HostScreen == null) + return; + + // TODO: When proper routing has been implemented, use that here + // Create a settings VM to navigate to + SettingsViewModel settingsViewModel = _getSettingsViewModel(_mainWindowService.HostScreen); + // Get the release tab + ReleasesTabViewModel releaseTabViewModel = (ReleasesTabViewModel) settingsViewModel.SettingTabs.First(t => t is ReleasesTabViewModel); + + // Navigate to the settings VM + _mainWindowService.HostScreen.Router.Navigate.Execute(settingsViewModel); + // Navigate to the release tab + releaseTabViewModel.PreselectVersion = version; + settingsViewModel.SelectedTab = releaseTabViewModel; + } +} \ 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..0dc3bf356 --- /dev/null +++ b/src/Artemis.UI/Services/Updating/IUpdateNotificationProvider.cs @@ -0,0 +1,9 @@ +using System; + +namespace Artemis.UI.Services.Updating; + +public interface IUpdateNotificationProvider +{ + void ShowNotification(Guid releaseId, string releaseVersion); + void ShowInstalledNotification(string installedVersion); +} \ No newline at end of file diff --git a/src/Artemis.UI/Services/Updating/IUpdateService.cs b/src/Artemis.UI/Services/Updating/IUpdateService.cs new file mode 100644 index 000000000..225ecbd97 --- /dev/null +++ b/src/Artemis.UI/Services/Updating/IUpdateService.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using Artemis.UI.Services.Interfaces; +using Artemis.WebClient.Updating; + +namespace Artemis.UI.Services.Updating; + +public interface IUpdateService : IArtemisUIService +{ + /// + /// Gets the current update channel. + /// + string Channel { get; } + + /// + /// Gets the version number of the previous release that was installed, if any. + /// + string? PreviousVersion { get; } + + /// + /// The latest cached release, can be updated by calling . + /// + IGetNextRelease_NextPublishedRelease? CachedLatestRelease { get; } + + /// + /// Asynchronously caches the latest release. + /// + Task CacheLatestRelease(); + + /// + /// Asynchronously checks whether an update is available on the current . + /// + Task CheckForUpdate(); + + /// + /// Creates a release installed for a release with the provided ID. + /// + /// The ID of the release to create the installer for. + /// The resulting release installer. + ReleaseInstaller GetReleaseInstaller(Guid releaseId); + + /// + /// Restarts the application to install a pending update. + /// + /// A boolean indicating whether to perform a silent install of the update. + void RestartForUpdate(bool silent); + + /// + /// Initializes the update service. + /// + /// A boolean indicating whether a restart will occur to install a pending update. + bool Initialize(); +} \ No newline at end of file diff --git a/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs b/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs new file mode 100644 index 000000000..04ca4f74f --- /dev/null +++ b/src/Artemis.UI/Services/Updating/ReleaseInstaller.cs @@ -0,0 +1,186 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.UI.Exceptions; +using Artemis.UI.Extensions; +using Artemis.WebClient.Updating; +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 : CorePropertyChanged +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly Guid _releaseId; + private readonly Platform _updatePlatform; + private readonly IUpdatingClient _updatingClient; + private readonly Progress _progress = new(); + + private IGetReleaseById_PublishedRelease _release = null!; + private IGetReleaseById_PublishedRelease_Artifacts _artifact = null!; + + private Progress _stepProgress = new(); + private string _status = string.Empty; + private float _floatProgress; + + public ReleaseInstaller(Guid releaseId, ILogger logger, IUpdatingClient updatingClient, HttpClient httpClient) + { + _releaseId = releaseId; + _logger = logger; + _updatingClient = updatingClient; + _httpClient = httpClient; + + if (OperatingSystem.IsWindows()) + _updatePlatform = Platform.Windows; + else if (OperatingSystem.IsLinux()) + _updatePlatform = Platform.Linux; + else if (OperatingSystem.IsMacOS()) + _updatePlatform = Platform.Osx; + else + throw new PlatformNotSupportedException("Cannot auto update on the current platform"); + + _progress.ProgressChanged += (_, f) => Progress = f; + } + + public string Status + { + get => _status; + private set => SetAndNotify(ref _status, value); + } + + public float Progress + { + get => _floatProgress; + set => SetAndNotify(ref _floatProgress, value); + } + + public async Task InstallAsync(CancellationToken cancellationToken) + { + _stepProgress = new Progress(); + + ((IProgress) _progress).Report(0); + + Status = "Retrieving details"; + _logger.Information("Retrieving details for release {ReleaseId}", _releaseId); + IOperationResult result = await _updatingClient.GetReleaseById.ExecuteAsync(_releaseId, cancellationToken); + result.EnsureNoErrors(); + + _release = result.Data?.PublishedRelease!; + if (_release == null) + throw new Exception($"Could not find release with ID {_releaseId}"); + + _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"); + + ((IProgress) _progress).Report(10); + + // Determine whether the last update matches our local version, then we can download the delta + if (_release.PreviousRelease != null && File.Exists(Path.Combine(Constants.UpdatingFolder, $"{_release.PreviousRelease.Version}.zip")) && _artifact.DeltaFileInfo.DownloadSize != 0) + await DownloadDelta(Path.Combine(Constants.UpdatingFolder, $"{_release.PreviousRelease.Version}.zip"), cancellationToken); + else + await Download(cancellationToken); + } + + private async Task DownloadDelta(string previousRelease, CancellationToken cancellationToken) + { + // 10 - 50% + _stepProgress.ProgressChanged += StepProgressOnProgressChanged; + void StepProgressOnProgressChanged(object? sender, float e) => ((IProgress) _progress).Report(10f + e * 0.4f); + + Status = "Downloading..."; + await using MemoryStream stream = new(); + await _httpClient.DownloadDataAsync($"https://updating.artemis-rgb.com/api/artifacts/{_artifact.ArtifactId}/delta", stream, _stepProgress, cancellationToken); + + _stepProgress.ProgressChanged -= StepProgressOnProgressChanged; + await PatchDelta(stream, previousRelease, cancellationToken); + } + + private async Task PatchDelta(Stream deltaStream, string previousRelease, CancellationToken cancellationToken) + { + // 50 - 60% + _stepProgress.ProgressChanged += StepProgressOnProgressChanged; + void StepProgressOnProgressChanged(object? sender, float e) => ((IProgress) _progress).Report(50f + e * 0.1f); + + Status = "Patching..."; + await using FileStream newFileStream = new(Path.Combine(Constants.UpdatingFolder, $"{_release.Version}.zip"), FileMode.Create, FileAccess.ReadWrite, FileShare.Read); + await using (FileStream baseStream = File.OpenRead(previousRelease)) + { + deltaStream.Seek(0, SeekOrigin.Begin); + DeltaApplier deltaApplier = new() {SkipHashCheck = true}; + // Patching is not async and so fast that it's not worth adding a progress reporter + deltaApplier.Apply(baseStream, new BinaryDeltaReader(deltaStream, new NullProgressReporter()), newFileStream); + cancellationToken.ThrowIfCancellationRequested(); + } + + // The previous release is no longer required now that the latest has been downloaded + File.Delete(previousRelease); + + _stepProgress.ProgressChanged -= StepProgressOnProgressChanged; + + await ValidateArchive(newFileStream, cancellationToken); + await Extract(newFileStream, cancellationToken); + } + + private async Task Download(CancellationToken cancellationToken) + { + // 10 - 60% + _stepProgress.ProgressChanged += StepProgressOnProgressChanged; + void StepProgressOnProgressChanged(object? sender, float e) => ((IProgress) _progress).Report(10f + e * 0.5f); + + Status = "Downloading..."; + await using FileStream stream = new(Path.Combine(Constants.UpdatingFolder, $"{_release.Version}.zip"), FileMode.Create, FileAccess.ReadWrite, FileShare.Read); + await _httpClient.DownloadDataAsync($"https://updating.artemis-rgb.com/api/artifacts/{_artifact.ArtifactId}", stream, _stepProgress, cancellationToken); + + _stepProgress.ProgressChanged -= StepProgressOnProgressChanged; + + await ValidateArchive(stream, cancellationToken); + await Extract(stream, cancellationToken); + } + + private async Task Extract(Stream archiveStream, CancellationToken cancellationToken) + { + // 60 - 100% + _stepProgress.ProgressChanged += StepProgressOnProgressChanged; + void StepProgressOnProgressChanged(object? sender, float e) => ((IProgress) _progress).Report(60f + e * 0.4f); + + Status = "Extracting..."; + // Ensure the directory is empty + string extractDirectory = Path.Combine(Constants.UpdatingFolder, "pending"); + if (Directory.Exists(extractDirectory)) + Directory.Delete(extractDirectory, true); + Directory.CreateDirectory(extractDirectory); + + await Task.Run(() => + { + archiveStream.Seek(0, SeekOrigin.Begin); + using ZipArchive archive = new(archiveStream); + archive.ExtractToDirectory(extractDirectory, false, _stepProgress, cancellationToken); + }, cancellationToken); + + ((IProgress) _progress).Report(100); + _stepProgress.ProgressChanged -= StepProgressOnProgressChanged; + } + + private async Task ValidateArchive(Stream archiveStream, CancellationToken cancellationToken) + { + using MD5 md5 = MD5.Create(); + archiveStream.Seek(0, SeekOrigin.Begin); + string hash = BitConverter.ToString(await md5.ComputeHashAsync(archiveStream, cancellationToken)).Replace("-", ""); + if (hash != _artifact.FileInfo.Md5Hash) + throw new ArtemisUIException($"Update file hash mismatch, expected \"{_artifact.FileInfo.Md5Hash}\" but got \"{hash}\""); + } +} \ 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..fee0b93b0 --- /dev/null +++ b/src/Artemis.UI/Services/Updating/UpdateService.cs @@ -0,0 +1,251 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Core; +using Artemis.Core.Services; +using Artemis.Storage.Entities.General; +using Artemis.Storage.Repositories; +using Artemis.UI.Exceptions; +using Artemis.UI.Shared.Services.MainWindow; +using Artemis.WebClient.Updating; +using Serilog; +using StrawberryShake; +using Timer = System.Timers.Timer; + +namespace Artemis.UI.Services.Updating; + +public class UpdateService : IUpdateService +{ + private const double UPDATE_CHECK_INTERVAL = 3_600_000; // once per hour + private readonly PluginSetting _autoCheck; + private readonly PluginSetting _autoInstall; + private readonly Func _getReleaseInstaller; + + private readonly ILogger _logger; + private readonly IMainWindowService _mainWindowService; + private readonly IReleaseRepository _releaseRepository; + private readonly Lazy _updateNotificationProvider; + private readonly Platform _updatePlatform; + private readonly IUpdatingClient _updatingClient; + + private bool _suspendAutoCheck; + private DateTime _lastAutoUpdateCheck; + + public UpdateService(ILogger logger, + ISettingsService settingsService, + IMainWindowService mainWindowService, + IUpdatingClient updatingClient, + IReleaseRepository releaseRepository, + Lazy updateNotificationProvider, + Func getReleaseInstaller) + { + _logger = logger; + _mainWindowService = mainWindowService; + _updatingClient = updatingClient; + _releaseRepository = releaseRepository; + _updateNotificationProvider = updateNotificationProvider; + _getReleaseInstaller = getReleaseInstaller; + + if (OperatingSystem.IsWindows()) + _updatePlatform = Platform.Windows; + else if (OperatingSystem.IsLinux()) + _updatePlatform = Platform.Linux; + else if (OperatingSystem.IsMacOS()) + _updatePlatform = Platform.Osx; + else + throw new PlatformNotSupportedException("Cannot auto update on the current platform"); + + _autoCheck = settingsService.GetSetting("UI.Updating.AutoCheck", true); + _autoInstall = settingsService.GetSetting("UI.Updating.AutoInstall", true); + _autoCheck.SettingChanged += HandleAutoUpdateEvent; + mainWindowService.MainWindowOpened += HandleAutoUpdateEvent; + Timer timer = new(UPDATE_CHECK_INTERVAL); + timer.Elapsed += HandleAutoUpdateEvent; + timer.Start(); + } + + private void ProcessReleaseStatus() + { + string currentVersion = Constants.CurrentVersion; + bool updated = _releaseRepository.SaveVersionInstallDate(currentVersion); + PreviousVersion = _releaseRepository.GetPreviousInstalledVersion()?.Version; + + if (!Directory.Exists(Constants.UpdatingFolder)) + return; + + // Clean up the update folder, leaving only the last ZIP + foreach (string file in Directory.GetFiles(Constants.UpdatingFolder)) + { + if (Path.GetExtension(file) != ".zip" || Path.GetFileName(file) == $"{currentVersion}.zip") + continue; + + try + { + _logger.Debug("Cleaning up old update file at {FilePath}", file); + File.Delete(file); + } + catch (Exception e) + { + _logger.Warning(e, "Failed to clean up old update file at {FilePath}", file); + } + } + + if (updated) + _updateNotificationProvider.Value.ShowInstalledNotification(currentVersion); + } + + private void ShowUpdateNotification(IGetNextRelease_NextPublishedRelease release) + { + _updateNotificationProvider.Value.ShowNotification(release.Id, release.Version); + } + + private async Task AutoInstallUpdate(IGetNextRelease_NextPublishedRelease release) + { + ReleaseInstaller installer = _getReleaseInstaller(release.Id); + await installer.InstallAsync(CancellationToken.None); + RestartForUpdate(true); + } + + private async void HandleAutoUpdateEvent(object? sender, EventArgs e) + { + // The event can trigger from multiple sources with a timer acting as a fallback, only actual perform an action once per max 59 minutes + if (DateTime.UtcNow - _lastAutoUpdateCheck < TimeSpan.FromMinutes(59)) + return; + _lastAutoUpdateCheck = DateTime.UtcNow; + + if (!_autoCheck.Value || _suspendAutoCheck) + return; + + try + { + await CheckForUpdate(); + } + catch (Exception ex) + { + _logger.Warning(ex, "Auto update-check failed"); + } + } + + /// + public string Channel { get; private set; } = "master"; + + /// + public string? PreviousVersion { get; private set; } + + /// + public IGetNextRelease_NextPublishedRelease? CachedLatestRelease { get; private set; } + + /// + public async Task CacheLatestRelease() + { + try + { + IOperationResult result = await _updatingClient.GetNextRelease.ExecuteAsync(Constants.CurrentVersion, Channel, _updatePlatform); + CachedLatestRelease = result.Data?.NextPublishedRelease; + } + catch (Exception e) + { + _logger.Warning(e, "Failed to cache latest release"); + } + } + + /// + public async Task CheckForUpdate() + { + _logger.Information("Performing auto-update check"); + + IOperationResult result = await _updatingClient.GetNextRelease.ExecuteAsync(Constants.CurrentVersion, Channel, _updatePlatform); + result.EnsureNoErrors(); + + // Update cache + CachedLatestRelease = result.Data?.NextPublishedRelease; + + // No update was found + if (CachedLatestRelease == null) + return false; + + // Unless auto install is enabled, only offer it once per session + if (!_autoInstall.Value) + _suspendAutoCheck = true; + + // If the window is open show the changelog, don't auto-update while the user is busy + if (_mainWindowService.IsMainWindowOpen || !_autoInstall.Value) + { + _logger.Information("New update available, offering version {AvailableVersion}", CachedLatestRelease.Version); + ShowUpdateNotification(CachedLatestRelease); + } + else + { + _logger.Information("New update available, auto-installing version {AvailableVersion}", CachedLatestRelease.Version); + await AutoInstallUpdate(CachedLatestRelease); + } + + return true; + } + + /// + public ReleaseInstaller GetReleaseInstaller(Guid releaseId) + { + return _getReleaseInstaller(releaseId); + } + + /// + public void RestartForUpdate(bool silent) + { + if (!Directory.Exists(Path.Combine(Constants.UpdatingFolder, "pending"))) + throw new ArtemisUIException("Cannot install update, none is pending."); + + Directory.Move(Path.Combine(Constants.UpdatingFolder, "pending"), Path.Combine(Constants.UpdatingFolder, "installing")); + Utilities.ApplyUpdate(silent); + } + + /// + public bool Initialize() + { + string? channelArgument = Constants.StartupArguments.FirstOrDefault(a => a.StartsWith("--channel=")); + if (channelArgument != null) + Channel = channelArgument.Split("=")[1]; + if (string.IsNullOrWhiteSpace(Channel)) + Channel = "master"; + + // There should never be an installing folder + if (Directory.Exists(Path.Combine(Constants.UpdatingFolder, "installing"))) + { + _logger.Warning("Cleaning up leftover installing folder, did an update go wrong?"); + try + { + Directory.Delete(Path.Combine(Constants.UpdatingFolder, "installing"), true); + } + catch (Exception e) + { + _logger.Error(e, "Failed to delete leftover installing folder"); + } + } + + // If an update is pending, don't bother with anything else + if (Directory.Exists(Path.Combine(Constants.UpdatingFolder, "pending"))) + { + _logger.Information("Installing pending update"); + try + { + RestartForUpdate(true); + return true; + } + catch (Exception e) + { + _logger.Warning(e, "Failed to apply pending update"); + return false; + } + } + + ProcessReleaseStatus(); + + // Trigger the auto update event so that it doesn't take an hour for the first check to happen + HandleAutoUpdateEvent(this, EventArgs.Empty); + + _logger.Information("Update service initialized for {Channel} channel", Channel); + return false; + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Styles/Artemis.axaml b/src/Artemis.UI/Styles/Artemis.axaml index b10f8bdb6..d92851a90 100644 --- a/src/Artemis.UI/Styles/Artemis.axaml +++ b/src/Artemis.UI/Styles/Artemis.axaml @@ -6,4 +6,5 @@ + \ No newline at end of file diff --git a/src/Artemis.UI/Styles/Markdown.axaml b/src/Artemis.UI/Styles/Markdown.axaml new file mode 100644 index 000000000..34acbe75f --- /dev/null +++ b/src/Artemis.UI/Styles/Markdown.axaml @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ## Core + * Cleaned up ProfileService render condition + * Core - Added fading in and out of profiles + * Core - Apply opacity layer only when fading + * Core - Fixed when condition stops being true mid-fade + * Core - Removed FadingStatus enum + + # General + - Meta - Fixed warnings + - Meta - Update RGB.NET + + # Plugins + - Plugins - Ignore version when loading shared assemblies + + # UI + - Sidebar - Improved category reordering code + + + + + + \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/.config/dotnet-tools.json b/src/Artemis.WebClient.Updating/.config/dotnet-tools.json new file mode 100644 index 000000000..7d8626c03 --- /dev/null +++ b/src/Artemis.WebClient.Updating/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "strawberryshake.tools": { + "version": "13.0.0-rc.4", + "commands": [ + "dotnet-graphql" + ] + } + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/.graphqlconfig b/src/Artemis.WebClient.Updating/.graphqlconfig new file mode 100644 index 000000000..727ec86a0 --- /dev/null +++ b/src/Artemis.WebClient.Updating/.graphqlconfig @@ -0,0 +1,15 @@ +{ + "name": "Untitled GraphQL Schema", + "schemaPath": "schema.graphql", + "extensions": { + "endpoints": { + "Default GraphQL Endpoint": { + "url": "https://updating.artemis-rgb.com/graphql", + "headers": { + "user-agent": "JS GraphQL" + }, + "introspect": true + } + } + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/.graphqlrc.json b/src/Artemis.WebClient.Updating/.graphqlrc.json new file mode 100644 index 000000000..d500e1cee --- /dev/null +++ b/src/Artemis.WebClient.Updating/.graphqlrc.json @@ -0,0 +1,22 @@ +{ + "schema": "schema.graphql", + "documents": "**/*.graphql", + "extensions": { + "strawberryShake": { + "name": "UpdatingClient", + "namespace": "Artemis.WebClient.Updating", + "url": "https://updating.artemis-rgb.com/graphql/", + "emitGeneratedCode": false, + "records": { + "inputs": false, + "entities": false + }, + "transportProfiles": [ + { + "default": "Http", + "subscription": "WebSocket" + } + ] + } + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj b/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj new file mode 100644 index 000000000..f087e1de1 --- /dev/null +++ b/src/Artemis.WebClient.Updating/Artemis.WebClient.Updating.csproj @@ -0,0 +1,15 @@ + + + + net6.0 + enable + enable + + + + + + + + + diff --git a/src/Artemis.WebClient.Updating/DryIoc/ContainerExtensions.cs b/src/Artemis.WebClient.Updating/DryIoc/ContainerExtensions.cs new file mode 100644 index 000000000..e52e454d3 --- /dev/null +++ b/src/Artemis.WebClient.Updating/DryIoc/ContainerExtensions.cs @@ -0,0 +1,26 @@ +using DryIoc; +using DryIoc.Microsoft.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +namespace Artemis.WebClient.Updating.DryIoc; + +/// +/// Provides an extension method to register services onto a DryIoc . +/// +public static class ContainerExtensions +{ + /// + /// Registers the updating client into the container. + /// + /// The builder building the current container + public static void RegisterUpdatingClient(this IContainer container) + { + ServiceCollection serviceCollection = new(); + serviceCollection + .AddHttpClient() + .AddUpdatingClient() + .ConfigureHttpClient(client => client.BaseAddress = new Uri("https://updating.artemis-rgb.com/graphql")); + + container.WithDependencyInjectionAdapter(serviceCollection); + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/Queries/GetNextRelease.graphql b/src/Artemis.WebClient.Updating/Queries/GetNextRelease.graphql new file mode 100644 index 000000000..6ec87cc1b --- /dev/null +++ b/src/Artemis.WebClient.Updating/Queries/GetNextRelease.graphql @@ -0,0 +1,6 @@ +query GetNextRelease($currentVersion: String, $branch: String!, $platform: Platform!) { + nextPublishedRelease(version: $currentVersion, branch: $branch, platform: $platform) { + id + version + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql b/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql new file mode 100644 index 000000000..584445b52 --- /dev/null +++ b/src/Artemis.WebClient.Updating/Queries/GetReleaseById.graphql @@ -0,0 +1,26 @@ +query GetReleaseById($id: UUID!) { + publishedRelease(id: $id) { + branch + commit + version + previousRelease { + version + } + changelog + artifacts { + platform + artifactId + fileInfo { + ...fileInfo + } + deltaFileInfo { + ...fileInfo + } + } + } +} + +fragment fileInfo on ArtifactFileInfo { + md5Hash + downloadSize +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/Queries/GetReleases.graphql b/src/Artemis.WebClient.Updating/Queries/GetReleases.graphql new file mode 100644 index 000000000..0297b3f39 --- /dev/null +++ b/src/Artemis.WebClient.Updating/Queries/GetReleases.graphql @@ -0,0 +1,22 @@ +query GetReleases($branch: String!, $platform: Platform!, $take: Int!, $after: String) { + publishedReleases( + first: $take + after: $after + where: { + and: [ + { branch: { eq: $branch } } + { artifacts: { some: { platform: { eq: $platform } } } } + ] + } + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + version + createdAt + } + } +} \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/schema.extensions.graphql b/src/Artemis.WebClient.Updating/schema.extensions.graphql new file mode 100644 index 000000000..0b5fbd98b --- /dev/null +++ b/src/Artemis.WebClient.Updating/schema.extensions.graphql @@ -0,0 +1,13 @@ +scalar _KeyFieldSet + +directive @key(fields: _KeyFieldSet!) on SCHEMA | OBJECT + +directive @serializationType(name: String!) on SCALAR + +directive @runtimeType(name: String!) on SCALAR + +directive @enumValue(value: String!) on ENUM_VALUE + +directive @rename(name: String!) on INPUT_FIELD_DEFINITION | INPUT_OBJECT | ENUM | ENUM_VALUE + +extend schema @key(fields: "id") \ No newline at end of file diff --git a/src/Artemis.WebClient.Updating/schema.graphql b/src/Artemis.WebClient.Updating/schema.graphql new file mode 100644 index 000000000..fe855cb6c --- /dev/null +++ b/src/Artemis.WebClient.Updating/schema.graphql @@ -0,0 +1,319 @@ +# This file was generated based on ".graphqlconfig". Do not edit manually. + +schema { + query: Query + mutation: Mutation +} + +type ArtemisChannel { + branch: String! + releases: Int! +} + +type Artifact { + artifactId: Long! + deltaFileInfo: ArtifactFileInfo! + fileInfo: ArtifactFileInfo! + id: UUID! + platform: Platform! +} + +type ArtifactFileInfo { + downloadSize: Long! + downloads: Long! + id: UUID! + md5Hash: String +} + +"Information about the offset pagination." +type CollectionSegmentInfo { + "Indicates whether more items exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more items exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! +} + +type Mutation { + updateReleaseChangelog(input: UpdateReleaseChangelogInput!): UpdateReleaseChangelogPayload! +} + +"Information about pagination in a connection." +type PageInfo { + "When paginating forwards, the cursor to continue." + endCursor: String + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String +} + +"A connection to a list of items." +type PublishedReleasesConnection { + "A list of edges." + edges: [PublishedReleasesEdge!] + "A flattened list of the nodes." + nodes: [Release!] + "Information to aid in pagination." + pageInfo: PageInfo! + "Identifies the total count of items in the connection." + totalCount: Int! +} + +"An edge in a connection." +type PublishedReleasesEdge { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: Release! +} + +type Query { + channelByBranch(branch: String!): ArtemisChannel + channels: [ArtemisChannel!]! + nextPublishedRelease(branch: String!, platform: Platform!, version: String): Release + publishedChannels: [String!]! + publishedRelease(id: UUID!): Release + publishedReleases( + "Returns the elements in the list that come after the specified cursor." + after: String, + "Returns the elements in the list that come before the specified cursor." + before: String, + "Returns the first _n_ elements from the list." + first: Int, + "Returns the last _n_ elements from the list." + last: Int, + order: [ReleaseSortInput!], + where: ReleaseFilterInput + ): PublishedReleasesConnection + release(id: UUID!): Release + releaseStatistics(order: [ReleaseStatisticSortInput!], where: ReleaseStatisticFilterInput): [ReleaseStatistic!]! + releases(order: [ReleaseSortInput!], skip: Int, take: Int, where: ReleaseFilterInput): ReleasesCollectionSegment +} + +type Release { + artifacts: [Artifact!]! + branch: String! + changelog: String! + commit: String! + createdAt: DateTime! + id: UUID! + isDraft: Boolean! + previousRelease: Release + version: String! + workflowRunId: Long! +} + +type ReleaseStatistic { + count: Int! + lastReportedUsage: DateTime! + linuxCount: Int! + osxCount: Int! + releaseId: UUID! + windowsCount: Int! +} + +"A segment of a collection." +type ReleasesCollectionSegment { + "A flattened list of the items." + items: [Release!] + "Information to aid in pagination." + pageInfo: CollectionSegmentInfo! + totalCount: Int! +} + +type UpdateReleaseChangelogPayload { + release: Release +} + +enum ApplyPolicy { + AFTER_RESOLVER + BEFORE_RESOLVER + VALIDATION +} + +enum Platform { + LINUX + OSX + WINDOWS +} + +enum SortEnumType { + ASC + DESC +} + +"The `DateTime` scalar represents an ISO-8601 compliant date time type." +scalar DateTime + +"The `Long` scalar type represents non-fractional signed whole 64-bit numeric values. Long can represent values between -(2^63) and 2^63 - 1." +scalar Long + +scalar UUID + +input ArtifactFileInfoFilterInput { + and: [ArtifactFileInfoFilterInput!] + downloadSize: LongOperationFilterInput + downloads: LongOperationFilterInput + id: UuidOperationFilterInput + md5Hash: StringOperationFilterInput + or: [ArtifactFileInfoFilterInput!] +} + +input ArtifactFilterInput { + and: [ArtifactFilterInput!] + artifactId: LongOperationFilterInput + deltaFileInfo: ArtifactFileInfoFilterInput + fileInfo: ArtifactFileInfoFilterInput + id: UuidOperationFilterInput + or: [ArtifactFilterInput!] + platform: PlatformOperationFilterInput +} + +input BooleanOperationFilterInput { + eq: Boolean + neq: Boolean +} + +input DateTimeOperationFilterInput { + eq: DateTime + gt: DateTime + gte: DateTime + in: [DateTime] + lt: DateTime + lte: DateTime + neq: DateTime + ngt: DateTime + ngte: DateTime + nin: [DateTime] + nlt: DateTime + nlte: DateTime +} + +input IntOperationFilterInput { + eq: Int + gt: Int + gte: Int + in: [Int] + lt: Int + lte: Int + neq: Int + ngt: Int + ngte: Int + nin: [Int] + nlt: Int + nlte: Int +} + +input ListFilterInputTypeOfArtifactFilterInput { + all: ArtifactFilterInput + any: Boolean + none: ArtifactFilterInput + some: ArtifactFilterInput +} + +input LongOperationFilterInput { + eq: Long + gt: Long + gte: Long + in: [Long] + lt: Long + lte: Long + neq: Long + ngt: Long + ngte: Long + nin: [Long] + nlt: Long + nlte: Long +} + +input PlatformOperationFilterInput { + eq: Platform + in: [Platform!] + neq: Platform + nin: [Platform!] +} + +input ReleaseFilterInput { + and: [ReleaseFilterInput!] + artifacts: ListFilterInputTypeOfArtifactFilterInput + branch: StringOperationFilterInput + changelog: StringOperationFilterInput + commit: StringOperationFilterInput + createdAt: DateTimeOperationFilterInput + id: UuidOperationFilterInput + isDraft: BooleanOperationFilterInput + or: [ReleaseFilterInput!] + previousRelease: ReleaseFilterInput + version: StringOperationFilterInput + workflowRunId: LongOperationFilterInput +} + +input ReleaseSortInput { + branch: SortEnumType + changelog: SortEnumType + commit: SortEnumType + createdAt: SortEnumType + id: SortEnumType + isDraft: SortEnumType + previousRelease: ReleaseSortInput + version: SortEnumType + workflowRunId: SortEnumType +} + +input ReleaseStatisticFilterInput { + and: [ReleaseStatisticFilterInput!] + count: IntOperationFilterInput + lastReportedUsage: DateTimeOperationFilterInput + linuxCount: IntOperationFilterInput + or: [ReleaseStatisticFilterInput!] + osxCount: IntOperationFilterInput + releaseId: UuidOperationFilterInput + windowsCount: IntOperationFilterInput +} + +input ReleaseStatisticSortInput { + count: SortEnumType + lastReportedUsage: SortEnumType + linuxCount: SortEnumType + osxCount: SortEnumType + releaseId: SortEnumType + windowsCount: SortEnumType +} + +input StringOperationFilterInput { + and: [StringOperationFilterInput!] + contains: String + endsWith: String + eq: String + in: [String] + ncontains: String + nendsWith: String + neq: String + nin: [String] + nstartsWith: String + or: [StringOperationFilterInput!] + startsWith: String +} + +input UpdateReleaseChangelogInput { + changelog: String! + id: UUID! + isDraft: Boolean! +} + +input UuidOperationFilterInput { + eq: UUID + gt: UUID + gte: UUID + in: [UUID] + lt: UUID + lte: UUID + neq: UUID + ngt: UUID + ngte: UUID + nin: [UUID] + nlt: UUID + nlte: UUID +} diff --git a/src/Artemis.sln b/src/Artemis.sln index dec26a200..2e53b5d9a 100644 --- a/src/Artemis.sln +++ b/src/Artemis.sln @@ -19,6 +19,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Artemis.UI.MacOS", "Artemis EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Artemis.VisualScripting", "Artemis.VisualScripting\Artemis.VisualScripting.csproj", "{412B921A-26F5-4AE6-8B32-0C19BE54F421}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Artemis.WebClient.Updating", "Artemis.WebClient.Updating\Artemis.WebClient.Updating.csproj", "{7C8C6F50-0CC8-45B3-B608-A7218C005E4B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -57,6 +59,10 @@ Global {412B921A-26F5-4AE6-8B32-0C19BE54F421}.Debug|x64.Build.0 = Debug|x64 {412B921A-26F5-4AE6-8B32-0C19BE54F421}.Release|x64.ActiveCfg = Release|x64 {412B921A-26F5-4AE6-8B32-0C19BE54F421}.Release|x64.Build.0 = Release|x64 + {7C8C6F50-0CC8-45B3-B608-A7218C005E4B}.Debug|x64.ActiveCfg = Debug|Any CPU + {7C8C6F50-0CC8-45B3-B608-A7218C005E4B}.Debug|x64.Build.0 = Debug|Any CPU + {7C8C6F50-0CC8-45B3-B608-A7218C005E4B}.Release|x64.ActiveCfg = Release|Any CPU + {7C8C6F50-0CC8-45B3-B608-A7218C005E4B}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE