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.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/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/ApplicationStateManager.cs b/src/Artemis.UI.Windows/ApplicationStateManager.cs index 2eb3ce47f..1a7d8147e 100644 --- a/src/Artemis.UI.Windows/ApplicationStateManager.cs +++ b/src/Artemis.UI.Windows/ApplicationStateManager.cs @@ -108,8 +108,6 @@ public class ApplicationStateManager ProcessStartInfo info = new() { Arguments = $"-File {script} {source} {destination} {args}", - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, FileName = "PowerShell.exe" }; Process.Start(info); diff --git a/src/Artemis.UI.Windows/Scripts/update.ps1 b/src/Artemis.UI.Windows/Scripts/update.ps1 index 4247178dc..7603b013e 100644 --- a/src/Artemis.UI.Windows/Scripts/update.ps1 +++ b/src/Artemis.UI.Windows/Scripts/update.ps1 @@ -4,6 +4,10 @@ param ( [Parameter(Mandatory=$false)][string]$artemisArgs ) +Write-Host "Artemis update script v1" +Write-Host "Please do not close this window, this should not take long" +Write-Host "" + # Wait up to 10 seconds for the process to shut down for ($i=1; $i -le 10; $i++) { $process = Get-Process -Name Artemis.UI.Windows -ErrorAction SilentlyContinue @@ -26,12 +30,17 @@ if (!(Test-Path $destinationDirectory)) { Write-Error "The destination directory does not exist" } + # If the destination directory exists, clear it +Write-Host "Cleaning up old version where needed" Get-ChildItem $destinationDirectory | Remove-Item -Recurse -Force # Move the contents of the source directory to the destination directory +Write-Host "Installing new files" Get-ChildItem $sourceDirectory | Move-Item -Destination $destinationDirectory + +Write-Host "Finished! Restarting Artemis" Start-Sleep -Seconds 1 # When finished, run the updated version diff --git a/src/Artemis.UI/Artemis.UI.csproj b/src/Artemis.UI/Artemis.UI.csproj index 086380658..502846ca4 100644 --- a/src/Artemis.UI/Artemis.UI.csproj +++ b/src/Artemis.UI/Artemis.UI.csproj @@ -43,4 +43,15 @@ + + + + UpdatingTabView.axaml + Code + + + UpdatingTabView.axaml + Code + + \ No newline at end of file diff --git a/src/Artemis.UI/DryIoc/ContainerExtensions.cs b/src/Artemis.UI/DryIoc/ContainerExtensions.cs index 58970b2fd..da1dcd815 100644 --- a/src/Artemis.UI/DryIoc/ContainerExtensions.cs +++ b/src/Artemis.UI/DryIoc/ContainerExtensions.cs @@ -31,14 +31,13 @@ public static class UIContainerExtensions 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..8b3c5fa42 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(string releaseId, string version, DateTimeOffset createdAt); +} +public class ReleaseVmFactory : IReleaseVmFactory +{ + private readonly IContainer _container; + + public ReleaseVmFactory(IContainer container) + { + _container = container; + } + + public ReleaseViewModel ReleaseListViewModel(string 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/Screens/Settings/SettingsViewModel.cs b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs index 0a372c29b..773656c1f 100644 --- a/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/SettingsViewModel.cs @@ -10,6 +10,7 @@ public class SettingsViewModel : MainScreenViewModel GeneralTabViewModel generalTabViewModel, PluginsTabViewModel pluginsTabViewModel, DevicesTabViewModel devicesTabViewModel, + ReleasesTabViewModel releasesTabViewModel, AboutTabViewModel aboutTabViewModel) : base(hostScreen, "settings") { SettingTabs = new ObservableCollection @@ -17,6 +18,7 @@ public class SettingsViewModel : MainScreenViewModel generalTabViewModel, pluginsTabViewModel, devicesTabViewModel, + releasesTabViewModel, aboutTabViewModel }; } 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..6528d8b04 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabView.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + \ 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..61da057c8 --- /dev/null +++ b/src/Artemis.UI/Screens/Settings/Tabs/ReleasesTabViewModel.cs @@ -0,0 +1,106 @@ +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 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; + _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; + this.WhenActivated(async d => + { + await updateService.CacheLatestRelease(); + await GetMoreReleases(d.AsCancellationToken()); + SelectedReleaseViewModel = ReleaseViewModels.FirstOrDefault(); + }); + } + + public ReadOnlyObservableCollection ReleaseViewModels { get; } + + 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("feature/gh-actions", 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/ReleaseAvailableView.axaml.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseAvailableView.axaml.cs deleted file mode 100644 index ed0373da4..000000000 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseAvailableView.axaml.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Artemis.UI.Shared; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; -using Avalonia.ReactiveUI; - -namespace Artemis.UI.Screens.Settings.Updating; - -public partial class ReleaseAvailableView : ReactiveCoreWindow -{ - public ReleaseAvailableView() - { - InitializeComponent(); -#if DEBUG - this.AttachDevTools(); -#endif - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - - private void Button_OnClick(object? sender, RoutedEventArgs e) - { - Close(); - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseAvailableViewModel.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseAvailableViewModel.cs deleted file mode 100644 index 118177c7f..000000000 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseAvailableViewModel.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Reactive; -using System.Reactive.Linq; -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.WebClient.Updating; -using ReactiveUI; -using Serilog; -using StrawberryShake; - -namespace Artemis.UI.Screens.Settings.Updating; - -public class ReleaseAvailableViewModel : ActivatableViewModelBase -{ - private readonly string _nextReleaseId; - private readonly ILogger _logger; - private readonly IUpdateService _updateService; - private readonly IUpdatingClient _updatingClient; - private readonly INotificationService _notificationService; - private IGetReleaseById_Release? _release; - - public ReleaseAvailableViewModel(string nextReleaseId, ILogger logger, IUpdateService updateService, IUpdatingClient updatingClient, INotificationService notificationService) - { - _nextReleaseId = nextReleaseId; - _logger = logger; - _updateService = updateService; - _updatingClient = updatingClient; - _notificationService = notificationService; - - CurrentVersion = _updateService.CurrentVersion ?? "Development build"; - Install = ReactiveCommand.Create(ExecuteInstall, this.WhenAnyValue(vm => vm.Release).Select(r => r != null)); - - this.WhenActivated(async d => await RetrieveRelease(d.AsCancellationToken())); - } - - private void ExecuteInstall() - { - _updateService.InstallRelease(_nextReleaseId); - } - - private async Task RetrieveRelease(CancellationToken cancellationToken) - { - IOperationResult result = await _updatingClient.GetReleaseById.ExecuteAsync(_nextReleaseId, cancellationToken); - // Borrow GraphQLClientException for messaging, how lazy of me.. - if (result.Errors.Count > 0) - { - GraphQLClientException exception = new(result.Errors); - _logger.Error(exception, "Failed to retrieve release details"); - _notificationService.CreateNotification().WithTitle("Failed to retrieve release details").WithMessage(exception.Message).Show(); - return; - } - - if (result.Data?.Release == null) - { - _notificationService.CreateNotification().WithTitle("Failed to retrieve release details").WithMessage("Release not found").Show(); - return; - } - - Release = result.Data.Release; - } - - public string CurrentVersion { get; } - - public IGetReleaseById_Release? Release - { - get => _release; - set => RaiseAndSetIfChanged(ref _release, value); - } - - public ReactiveCommand Install { get; } - public ReactiveCommand AskLater { get; } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseInstallerView.axaml b/src/Artemis.UI/Screens/Settings/Updating/ReleaseInstallerView.axaml deleted file mode 100644 index 46699f5ae..000000000 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseInstallerView.axaml +++ /dev/null @@ -1,40 +0,0 @@ - - - - Downloading & installing update... - - - - This should not take long, when finished Artemis must restart. - - - - - - - - Done, click restart to apply the update 🫡 - - - Restart when finished - - - - - - \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseInstallerView.axaml.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseInstallerView.axaml.cs deleted file mode 100644 index ec2a689e6..000000000 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseInstallerView.axaml.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Artemis.UI.Shared; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; - -namespace Artemis.UI.Screens.Settings.Updating; - -public partial class ReleaseInstallerView : ReactiveCoreWindow -{ - public ReleaseInstallerView() - { - InitializeComponent(); -#if DEBUG - this.AttachDevTools(); -#endif - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - - private void Cancel_OnClick(object? sender, RoutedEventArgs e) - { - Close(); - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseInstallerViewModel.cs b/src/Artemis.UI/Screens/Settings/Updating/ReleaseInstallerViewModel.cs deleted file mode 100644 index 79a80aa06..000000000 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseInstallerViewModel.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Reactive; -using System.Reactive.Disposables; -using System.Reactive.Linq; -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 ReactiveUI; - -namespace Artemis.UI.Screens.Settings.Updating; - -public class ReleaseInstallerViewModel : ActivatableViewModelBase -{ - private readonly ReleaseInstaller _releaseInstaller; - private readonly IWindowService _windowService; - private ObservableAsPropertyHelper? _overallProgress; - private ObservableAsPropertyHelper? _stepProgress; - private bool _ready; - private bool _restartWhenFinished; - - public ReleaseInstallerViewModel(ReleaseInstaller releaseInstaller, IWindowService windowService) - { - _releaseInstaller = releaseInstaller; - _windowService = windowService; - - Restart = ReactiveCommand.Create(() => Utilities.ApplyUpdate(false)); - this.WhenActivated(d => - { - _overallProgress = Observable.FromEventPattern(x => _releaseInstaller.OverallProgress.ProgressChanged += x, x => _releaseInstaller.OverallProgress.ProgressChanged -= x) - .Select(e => e.EventArgs) - .ToProperty(this, vm => vm.OverallProgress) - .DisposeWith(d); - _stepProgress = Observable.FromEventPattern(x => _releaseInstaller.StepProgress.ProgressChanged += x, x => _releaseInstaller.StepProgress.ProgressChanged -= x) - .Select(e => e.EventArgs) - .ToProperty(this, vm => vm.StepProgress) - .DisposeWith(d); - - Task.Run(() => InstallUpdate(d.AsCancellationToken())); - }); - } - - public ReactiveCommand Restart { get; } - - public float OverallProgress => _overallProgress?.Value ?? 0; - public float StepProgress => _stepProgress?.Value ?? 0; - - public bool Ready - { - get => _ready; - set => RaiseAndSetIfChanged(ref _ready, value); - } - - public bool RestartWhenFinished - { - get => _restartWhenFinished; - set => RaiseAndSetIfChanged(ref _restartWhenFinished, value); - } - - private async Task InstallUpdate(CancellationToken cancellationToken) - { - try - { - await _releaseInstaller.InstallAsync(cancellationToken); - Ready = true; - if (RestartWhenFinished) - Utilities.ApplyUpdate(false); - } - catch (TaskCanceledException) - { - // ignored - } - catch (Exception e) - { - _windowService.ShowExceptionDialog("Something went wrong while installing the update", e); - } - } -} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Updating/ReleaseAvailableView.axaml b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml similarity index 50% rename from src/Artemis.UI/Screens/Settings/Updating/ReleaseAvailableView.axaml rename to src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml index acf70d5b4..91e1a0a4c 100644 --- a/src/Artemis.UI/Screens/Settings/Updating/ReleaseAvailableView.axaml +++ b/src/Artemis.UI/Screens/Settings/Updating/ReleaseView.axaml @@ -1,49 +1,163 @@ - - + + + + + + + + + + + + + + - - A new Artemis update is available! 🥳 - + + + + + Release info - - Retrieving release... - - + + + + + + + + - - - - - - - - - - + + + - - - + + + + Ready, restart to install + + + + + - + + + + + + Release date + + + + + + Source + + + + + + File size + + + + + + + + + + Release notes + + +