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
@@ -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