diff --git a/ARESLauncher.Tests/AresUpdaterTests.cs b/ARESLauncher.Tests/AresUpdaterTests.cs index 0f6c21e..e2f6f08 100644 --- a/ARESLauncher.Tests/AresUpdaterTests.cs +++ b/ARESLauncher.Tests/AresUpdaterTests.cs @@ -1,5 +1,3 @@ -using System.IO; -using System.Threading.Tasks; using ARESLauncher.Configuration; using ARESLauncher.Models; using ARESLauncher.Services; @@ -80,6 +78,134 @@ public async Task Update_DownloadsAndUnpacksSinglePackage() } } + [Test] + public async Task GetAvailableVersions_IncludesDebugFolderPackages() + { + var debugPath = Path.Combine(AppContext.BaseDirectory, "Debug"); + Directory.CreateDirectory(debugPath); + var version = new SemanticVersion(8, 8, 8); + var archiveName = $"ARES-v{version.ToNormalizedString()}.zip"; + var archivePath = TestArchives.CreateArchive(debugPath, archiveName, ("empty", "")); + + try + { + var downloader = new RecordingAresDownloader(null); + downloader.AvailableReleases = [new AresRelease { Version = new SemanticVersion(1, 0, 0), IsBeta = false }]; + + var updater = new AresUpdater( + downloader, + new FakeAppConfigurationService(new LauncherConfiguration()), + new FakeAppSettingsUpdater(), + new FakeCertificateManager(), + new FakeDatabaseManager(), + new FakeAresBinaryManager(), + NullLogger.Instance); + + var releases = await updater.GetAvailableVersions(); + + Assert.That(releases.Select(r => r.Version), Does.Contain(version)); + Assert.That(releases.Select(r => r.Version), Does.Contain(new SemanticVersion(1, 0, 0))); + } + finally + { + if(File.Exists(archivePath)) File.Delete(archivePath); + } + } + + [Test] + public async Task GetAvailableVersions_FiltersBetaReleases_WhenNotOptedIn() + { + var downloader = new RecordingAresDownloader(null); + downloader.AvailableReleases = [ + new AresRelease { Version = new SemanticVersion(1, 0, 0), IsBeta = false }, + new AresRelease { Version = new SemanticVersion(1, 1, 0), IsBeta = true } + ]; + + var updater = new AresUpdater( + downloader, + new FakeAppConfigurationService(new LauncherConfiguration { IncludeBeta = false }), + new FakeAppSettingsUpdater(), + new FakeCertificateManager(), + new FakeDatabaseManager(), + new FakeAresBinaryManager(), + NullLogger.Instance); + + var releases = await updater.GetAvailableVersions(); + + Assert.That(releases.Select(r => r.Version), Does.Contain(new SemanticVersion(1, 0, 0))); + Assert.That(releases.Select(r => r.Version), Does.Not.Contain(new SemanticVersion(1, 1, 0))); + } + + [Test] + public async Task GetAvailableVersions_IncludesBetaReleases_WhenOptedIn() + { + var downloader = new RecordingAresDownloader(null); + downloader.AvailableReleases = [ + new AresRelease { Version = new SemanticVersion(1, 0, 0), IsBeta = false }, + new AresRelease { Version = new SemanticVersion(1, 1, 0), IsBeta = true } + ]; + + var updater = new AresUpdater( + downloader, + new FakeAppConfigurationService(new LauncherConfiguration { IncludeBeta = true }), + new FakeAppSettingsUpdater(), + new FakeCertificateManager(), + new FakeDatabaseManager(), + new FakeAresBinaryManager(), + NullLogger.Instance); + + var releases = await updater.GetAvailableVersions(); + + Assert.That(releases.Select(r => r.Version), Does.Contain(new SemanticVersion(1, 0, 0))); + Assert.That(releases.Select(r => r.Version), Does.Contain(new SemanticVersion(1, 1, 0))); + } + + [Test] + public async Task Update_UsesDebugFolderPackage_WhenAvailable() + { + var debugPath = Path.Combine(AppContext.BaseDirectory, "Debug"); + Directory.CreateDirectory(debugPath); + var tempRoot = TestPaths.CreateTempDirectory(); + var uiDir = Path.Combine(tempRoot, "ui"); + Directory.CreateDirectory(uiDir); + + var version = new SemanticVersion(9, 9, 9); + var archiveName = $"ARES-v{version.ToNormalizedString()}.zip"; + var archivePath = TestArchives.CreateArchive(debugPath, archiveName, ("debug.bin", "content")); + + try + { + var downloader = new RecordingAresDownloader(null); // Should not be called + var configuration = new FakeAppConfigurationService(new LauncherConfiguration + { + UiBinaryPath = uiDir, + ServiceBinaryPath = uiDir + }); + + var updater = new AresUpdater( + downloader, + configuration, + new FakeAppSettingsUpdater(), + new FakeCertificateManager(), + new FakeDatabaseManager(), + new FakeAresBinaryManager(), + NullLogger.Instance); + + await updater.Update(version); + + Assert.That(downloader.DownloadCallCount, Is.EqualTo(0)); + Assert.That(File.Exists(Path.Combine(uiDir, "debug.bin")), Is.True); + + var metadata = BinaryMetadataHelper.ReadMetadata(uiDir); + Assert.That(metadata!.Version, Is.EqualTo(version.ToNormalizedString())); + } + finally + { + if(File.Exists(archivePath)) File.Delete(archivePath); + TestPaths.DeleteDirectoryIfExists(tempRoot); + } + } + [Test] public async Task Update_PersistsSplitLayout_WhenServiceExecutableExists() { @@ -88,7 +214,8 @@ public async Task Update_PersistsSplitLayout_WhenServiceExecutableExists() try { - var archivePath = TestArchives.CreateArchive(tempRoot, "combined.zip", ("UI", "ui"), ("AresService", "svc")); + var serviceName = OperatingSystem.IsWindows() ? "AresService.exe" : "AresService"; + var archivePath = TestArchives.CreateArchive(tempRoot, "combined.zip", ("UI", "ui"), (serviceName, "svc")); var source = new AresSource("AFRL-ARES", "ARES"); var version = new SemanticVersion(2, 0, 0); var downloader = new RecordingAresDownloader(archivePath); @@ -120,4 +247,80 @@ public async Task Update_PersistsSplitLayout_WhenServiceExecutableExists() TestPaths.DeleteDirectoryIfExists(tempRoot); } } + + [Test] + public void InvalidateCache_DelegatesToDownloader() + { + var downloader = new RecordingAresDownloader(null); + var updater = new AresUpdater( + downloader, + new FakeAppConfigurationService(new LauncherConfiguration()), + new FakeAppSettingsUpdater(), + new FakeCertificateManager(), + new FakeDatabaseManager(), + new FakeAresBinaryManager(), + NullLogger.Instance); + + updater.InvalidateCache(); + + Assert.That(downloader.InvalidateCacheCalled, Is.True); + } + + [Test] + public async Task CreateSnapshot_CallsDatabaseManagerCreateSnapshot() + { + var dbManager = new FakeDatabaseManager(); + var updater = new AresUpdater( + new RecordingAresDownloader(null), + new FakeAppConfigurationService(new LauncherConfiguration()), + new FakeAppSettingsUpdater(), + new FakeCertificateManager(), + dbManager, + new FakeAresBinaryManager(), + NullLogger.Instance); + + var version = new SemanticVersion(1, 0, 0); + await updater.CreateSnapshot(version); + + Assert.That(dbManager.CreateSnapshotCallCount, Is.EqualTo(1)); + Assert.That(dbManager.LastSnapshotVersion, Is.EqualTo(version)); + } + + [Test] + public async Task RestoreSnapshot_CallsDatabaseManagerRestoreSnapshot() + { + var dbManager = new FakeDatabaseManager(); + var updater = new AresUpdater( + new RecordingAresDownloader(null), + new FakeAppConfigurationService(new LauncherConfiguration()), + new FakeAppSettingsUpdater(), + new FakeCertificateManager(), + dbManager, + new FakeAresBinaryManager(), + NullLogger.Instance); + + var version = new SemanticVersion(1, 0, 0); + await updater.RestoreSnapshot(version); + + Assert.That(dbManager.RestoreSnapshotCallCount, Is.EqualTo(1)); + Assert.That(dbManager.LastRestoreVersion, Is.EqualTo(version)); + } + + [Test] + public async Task ResetDatabase_CallsDatabaseManagerReset() + { + var dbManager = new FakeDatabaseManager(); + var updater = new AresUpdater( + new RecordingAresDownloader(null), + new FakeAppConfigurationService(new LauncherConfiguration()), + new FakeAppSettingsUpdater(), + new FakeCertificateManager(), + dbManager, + new FakeAresBinaryManager(), + NullLogger.Instance); + + await updater.ResetDatabase(); + + Assert.That(dbManager.DatabaseStatus, Is.EqualTo(DatabaseStatus.NonExistent)); + } } diff --git a/ARESLauncher.Tests/TestSupport/TestDoubles.cs b/ARESLauncher.Tests/TestSupport/TestDoubles.cs index c58067e..1bef91e 100644 --- a/ARESLauncher.Tests/TestSupport/TestDoubles.cs +++ b/ARESLauncher.Tests/TestSupport/TestDoubles.cs @@ -1,5 +1,3 @@ -using System; -using System.Threading.Tasks; using ARESLauncher.Configuration; using ARESLauncher.Models; using ARESLauncher.Models.AppSettings; @@ -9,22 +7,29 @@ namespace ARESLauncher.Tests; -internal sealed class RecordingAresDownloader(string archivePath) : IAresDownloader +internal sealed class RecordingAresDownloader(string? archivePath) : IAresDownloader { public int DownloadCallCount { get; private set; } + public bool InvalidateCacheCalled { get; private set; } public AresSource? LastSource { get; private set; } public SemanticVersion? LastVersion { get; private set; } public string? LastDestination { get; private set; } public string? LastAuthToken { get; private set; } + public AresRelease[] AvailableReleases { get; set; } = []; - public Task GetAvailableVersions(AresSource source, string? authToken) + public Task GetAvailableVersions(AresSource source, string? authToken) { - throw new NotSupportedException(); + return Task.FromResult(AvailableReleases); } - public Task GetAvailableVersions(LauncherSource soruce) + public Task GetAvailableVersions(LauncherSource source, string? authToken) { - throw new NotSupportedException(); + return Task.FromResult(AvailableReleases); + } + + public void InvalidateCache() + { + InvalidateCacheCalled = true; } public Task Download(LauncherSource source, SemanticVersion version, string destination, string? authToken, @@ -42,7 +47,7 @@ public Task Download(AresSource source, SemanticVersion version, string LastDestination = destination; LastAuthToken = authToken; progress?.Report(1); - return Task.FromResult(archivePath); + return Task.FromResult(archivePath ?? throw new InvalidOperationException("Archive path not set")); } } @@ -86,9 +91,14 @@ public Task Update() internal sealed class FakeDatabaseManager : IDatabaseManager { - public DatabaseStatus DatabaseStatus => DatabaseStatus.UpToDate; + public DatabaseStatus DatabaseStatus { get; set; } = DatabaseStatus.UpToDate; public int RefreshCallCount { get; private set; } public int RunMigrationsCallCount { get; private set; } + public int CreateSnapshotCallCount { get; private set; } + public int RestoreSnapshotCallCount { get; private set; } + public SemanticVersion? LastSnapshotVersion { get; private set; } + public SemanticVersion? LastRestoreVersion { get; private set; } + public bool SnapshotExistsResult { get; set; } public Task RunMigrations() { @@ -96,6 +106,31 @@ public Task RunMigrations() return Task.CompletedTask; } + public Task CreateSnapshot(SemanticVersion version) + { + CreateSnapshotCallCount++; + LastSnapshotVersion = version; + return Task.CompletedTask; + } + + public Task HasSnapshot(SemanticVersion version) + { + return Task.FromResult(SnapshotExistsResult); + } + + public Task RestoreSnapshot(SemanticVersion version) + { + RestoreSnapshotCallCount++; + LastRestoreVersion = version; + return Task.CompletedTask; + } + + public Task Reset() + { + DatabaseStatus = DatabaseStatus.NonExistent; + return Task.CompletedTask; + } + public Task Refresh() { RefreshCallCount++; diff --git a/ARESLauncher/Configuration/LauncherConfiguration.cs b/ARESLauncher/Configuration/LauncherConfiguration.cs index 4761ff4..3065ef7 100644 --- a/ARESLauncher/Configuration/LauncherConfiguration.cs +++ b/ARESLauncher/Configuration/LauncherConfiguration.cs @@ -41,4 +41,5 @@ public class LauncherConfiguration public string AresServiceProcessName { get; set; } = "AresService"; public string AresUiProcessName { get; set; } = "UI"; + public bool IncludeBeta { get; set; } = false; } diff --git a/ARESLauncher/Models/AppSettings/AppSettingsService.cs b/ARESLauncher/Models/AppSettings/AppSettingsService.cs index ee204a3..adc4c4d 100644 --- a/ARESLauncher/Models/AppSettings/AppSettingsService.cs +++ b/ARESLauncher/Models/AppSettings/AppSettingsService.cs @@ -3,5 +3,4 @@ namespace ARESLauncher.Models.AppSettings; public class AppSettingsService : AppSettingsBase { public TokensConfig? TokensConfig { get; set; } - public string? AresDataPath { get; set; } } \ No newline at end of file diff --git a/ARESLauncher/Models/AresRelease.cs b/ARESLauncher/Models/AresRelease.cs new file mode 100644 index 0000000..96ac003 --- /dev/null +++ b/ARESLauncher/Models/AresRelease.cs @@ -0,0 +1,15 @@ +using NuGet.Versioning; + +namespace ARESLauncher.Models; + +public class AresRelease +{ + public required SemanticVersion Version { get; init; } + public required bool IsBeta { get; init; } + public bool IsInstalled { get; set; } + + public override string ToString() + { + return IsBeta ? $"{Version} (Beta)" : Version.ToString(); + } +} diff --git a/ARESLauncher/Models/UpdateConfirmationRequest.cs b/ARESLauncher/Models/UpdateConfirmationRequest.cs index a8f6840..ae7d14d 100644 --- a/ARESLauncher/Models/UpdateConfirmationRequest.cs +++ b/ARESLauncher/Models/UpdateConfirmationRequest.cs @@ -2,8 +2,26 @@ namespace ARESLauncher.Models; +public enum DowngradeOption +{ + None, + RestoreSnapshot, + Reset, + Cancel +} + public class UpdateConfirmationRequest { public required SemanticVersion CurrentVersion { get; init; } public required SemanticVersion TargetVersion { get; init; } + public bool HasSnapshot { get; set; } +} + +public class UpdateConfirmationResponse +{ + public bool ShouldProceed { get; init; } + public DowngradeOption DowngradeOption { get; init; } = DowngradeOption.None; + + public static UpdateConfirmationResponse Cancel => new() { ShouldProceed = false, DowngradeOption = DowngradeOption.Cancel }; + public static UpdateConfirmationResponse Proceed(DowngradeOption option = DowngradeOption.None) => new() { ShouldProceed = true, DowngradeOption = option }; } diff --git a/ARESLauncher/Services/AresGithubDownloader.cs b/ARESLauncher/Services/AresGithubDownloader.cs index a0ad4cb..7a6c1a3 100644 --- a/ARESLauncher/Services/AresGithubDownloader.cs +++ b/ARESLauncher/Services/AresGithubDownloader.cs @@ -14,19 +14,38 @@ namespace ARESLauncher.Services; public partial class AresGithubDownloader(ILogger _logger) : IAresDownloader { private static readonly ApiOptions _fetchOptions = new() { PageCount = 2, PageSize = 10 }; + private static readonly Dictionary _cache = new(); + private static readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(30); - public async Task GetAvailableVersions(AresSource source, string? authToken) + public async Task GetAvailableVersions(AresSource source, string? authToken) { + return await GetCachedVersions(authToken, source.Owner, source.Repo); + } + + public async Task GetAvailableVersions(LauncherSource source, string? authToken) + { + return await GetCachedVersions(authToken, source.Owner, source.Repo); + } + + private async Task GetCachedVersions(string? authToken, string owner, string repo) + { + var cacheKey = $"{owner}/{repo}/{authToken ?? ""}"; + if(_cache.TryGetValue(cacheKey, out var cached) && DateTime.UtcNow - cached.timestamp < _cacheDuration) + { + return cached.releases; + } + var client = CreateClient(authToken); - var versions = await FetchAndNormalizeVersions(client, source.Owner, source.Repo); - return versions.ToArray(); + var versions = await FetchAndNormalizeVersions(client, owner, repo); + var result = versions.ToArray(); + + _cache[cacheKey] = (result, DateTime.UtcNow); + return result; } - public async Task GetAvailableVersions(LauncherSource source) + public void InvalidateCache() { - var client = CreateClient(""); - var versions = await FetchAndNormalizeVersions(client, source.Owner, source.Repo); - return versions.ToArray(); + _cache.Clear(); } public async Task Download(LauncherSource source, SemanticVersion version, string destination, string? authToken, @@ -46,9 +65,9 @@ public async Task Download(LauncherSource source, SemanticVersion versio : downloadResult.ResultingFilePath!; } - private async Task> FetchAndNormalizeVersions(GitHubClient client, string owner, string repo) + private async Task> FetchAndNormalizeVersions(GitHubClient client, string owner, string repo) { - var versions = new List(); + var versions = new List(); try { @@ -61,7 +80,7 @@ private async Task> FetchAndNormalizeVersions(GitHubClient continue; if(SemanticVersion.TryParse(normalizedTag, out var semanticVersion)) - versions.Add(semanticVersion); + versions.Add(new AresRelease { Version = semanticVersion, IsBeta = release.Prerelease }); } } diff --git a/ARESLauncher/Services/AresStarter.cs b/ARESLauncher/Services/AresStarter.cs index 38b1a37..c0af1a8 100644 --- a/ARESLauncher/Services/AresStarter.cs +++ b/ARESLauncher/Services/AresStarter.cs @@ -16,6 +16,7 @@ public class AresStarter : IAresStarter { private readonly IAresBinaryManager _aresBinaryManager; private readonly IExecutableGetter _executableGetter; + private readonly IDatabaseManager _databaseManager; private readonly ILogger _logger; private readonly BehaviorSubject _aresUiRunningSubject = new(false); private readonly BehaviorSubject _aresServiceRunningSubject = new(false); @@ -26,10 +27,11 @@ public class AresStarter : IAresStarter private CancellationTokenSource _cancellationTokenSource = new(); private int _stopInitiated = 0; - public AresStarter(IAresBinaryManager aresBinaryManager, IExecutableGetter executableGetter, ILogger logger) + public AresStarter(IAresBinaryManager aresBinaryManager, IExecutableGetter executableGetter, IDatabaseManager databaseManager, ILogger logger) { _aresBinaryManager = aresBinaryManager; _executableGetter = executableGetter; + _databaseManager = databaseManager; _logger = logger; AresUiRunning = _aresUiRunningSubject.AsObservable(); AresServiceRunning = _aresServiceRunningSubject.AsObservable(); @@ -38,7 +40,7 @@ public AresStarter(IAresBinaryManager aresBinaryManager, IExecutableGetter execu public IObservable AresUiRunning { get; } public IObservable AresServiceRunning { get; } - public void Start() + public async void Start() { if(IsHealthyRunning()) { @@ -46,6 +48,19 @@ public void Start() return; } + var currentVersion = _aresBinaryManager.CurrentVersion; + if(currentVersion is not null) + { + try + { + await _databaseManager.CreateSnapshot(currentVersion); + } + catch(Exception e) + { + _logger.LogWarning("Failed to create database snapshot before start: {Exception}", e); + } + } + _cancellationTokenSource = new CancellationTokenSource(); _stopInitiated = 0; diff --git a/ARESLauncher/Services/AresUpdater.cs b/ARESLauncher/Services/AresUpdater.cs index 0a16d31..6ceac69 100644 --- a/ARESLauncher/Services/AresUpdater.cs +++ b/ARESLauncher/Services/AresUpdater.cs @@ -45,10 +45,44 @@ public AresUpdater(IAresDownloader downloader, CurrentUpdateStep = _currentUpdateStepSubject.AsObservable(); } - public Task GetAvailableVersions() + public async Task GetAvailableVersions() { var source = _configurationService.Current.CurrentAresRepo; - return _downloader.GetAvailableVersions(source, _configurationService.Current.GitToken); + var remoteReleases = await _downloader.GetAvailableVersions(source, _configurationService.Current.GitToken); + var localDebugVersions = GetLocalDebugVersions(); + + var allReleases = remoteReleases.Union(localDebugVersions.Select(v => new AresRelease { Version = v, IsBeta = false })) + .OrderByDescending(r => r.Version); + + if(!_configurationService.Current.IncludeBeta) + { + return allReleases.Where(r => !r.IsBeta).ToArray(); + } + + return allReleases.ToArray(); + } + + private SemanticVersion[] GetLocalDebugVersions() + { + var debugPath = Path.Combine(AppContext.BaseDirectory, "Debug"); + if(!Directory.Exists(debugPath)) + return Array.Empty(); + + return Directory.EnumerateFiles(debugPath, "*.zip") + .Select(f => + { + try + { + return Tools.VersionExtensions.GetVersionFromZipName(Path.GetFileName(f)); + } + catch(Exception) + { + return null; + } + }) + .Where(v => v is not null) + .Cast() + .ToArray(); } public async Task InstallOffline(string path) @@ -147,7 +181,7 @@ public async Task UpdateLatest() return; } - var latest = versions.OrderDescending().FirstOrDefault(); + var latest = versions.FirstOrDefault(); if(latest is null) { _currentUpdateStepSubject.OnNext(UpdateStep.Idle); @@ -155,7 +189,62 @@ public async Task UpdateLatest() throw new InvalidOperationException("No ARES versions found. Ensure the right repository is selected and/or your Git token is correct."); } - await Update(latest); + await Update(latest.Version); + } + + public async Task CreateSnapshot(SemanticVersion version) + { + _currentUpdateStepSubject.OnNext(UpdateStep.Other); + _updateStepDescriptionSubject.OnNext("Creating database snapshot"); + try + { + await _databaseManager.CreateSnapshot(version); + } + finally + { + _currentUpdateStepSubject.OnNext(UpdateStep.Idle); + _updateStepDescriptionSubject.OnNext(""); + } + } + + public Task HasSnapshot(SemanticVersion version) + { + return _databaseManager.HasSnapshot(version); + } + + public async Task RestoreSnapshot(SemanticVersion version) + { + _currentUpdateStepSubject.OnNext(UpdateStep.Other); + _updateStepDescriptionSubject.OnNext("Restoring database snapshot"); + try + { + await _databaseManager.RestoreSnapshot(version); + } + finally + { + _currentUpdateStepSubject.OnNext(UpdateStep.Idle); + _updateStepDescriptionSubject.OnNext(""); + } + } + + public async Task ResetDatabase() + { + _currentUpdateStepSubject.OnNext(UpdateStep.Other); + _updateStepDescriptionSubject.OnNext("Resetting database"); + try + { + await _databaseManager.Reset(); + } + finally + { + _currentUpdateStepSubject.OnNext(UpdateStep.Idle); + _updateStepDescriptionSubject.OnNext(""); + } + } + + public void InvalidateCache() + { + _downloader.InvalidateCache(); } public IObservable UpdateStepDescription { get; } @@ -167,9 +256,37 @@ private async Task DownloadPackage(AresSource source, Semanti var tempPath = Path.GetTempPath(); try { - _updateStepDescriptionSubject.OnNext("Downloading the package."); - var packageDest = await _downloader.Download(source, version, tempPath, _configurationService.Current.GitToken, - new Progress(pg => _updateProgressSubject.OnNext(pg))); + string packageDest; + var debugPath = Path.Combine(AppContext.BaseDirectory, "Debug"); + var localPackage = Directory.Exists(debugPath) + ? Directory.EnumerateFiles(debugPath, "*.zip") + .FirstOrDefault(f => + { + try + { + return version.Equals(Tools.VersionExtensions.GetVersionFromZipName(Path.GetFileName(f))); + } + catch(Exception) + { + return false; + } + }) + : null; + + if(localPackage != null) + { + _logger.LogInformation("Using local debug package for version {Version}: {Path}", version, localPackage); + _updateStepDescriptionSubject.OnNext("Using local debug package."); + _updateProgressSubject.OnNext(1.0); + packageDest = localPackage; + } + else + { + _updateStepDescriptionSubject.OnNext("Downloading the package."); + packageDest = await _downloader.Download(source, version, tempPath, _configurationService.Current.GitToken, + new Progress(pg => _updateProgressSubject.OnNext(pg))); + } + _updateStepDescriptionSubject.OnNext("Unpacking the package"); await Unpacker.Unpack(packageDest, dest); var layout = DetectInstalledLayout(dest); diff --git a/ARESLauncher/Services/DatabaseManager.cs b/ARESLauncher/Services/DatabaseManager.cs index 8f06c48..a60b9d7 100644 --- a/ARESLauncher/Services/DatabaseManager.cs +++ b/ARESLauncher/Services/DatabaseManager.cs @@ -14,10 +14,8 @@ public class DatabaseManager(IExecutableGetter _executableGetter, IAppConfigurat public async Task RunMigrations() { var executable = GetDatabaseExecutablePath(); - if (executable is null) - { + if(executable is null) return; - } var workingDir = GetWorkingDir(executable); await Cli.Wrap(executable) @@ -31,10 +29,8 @@ await Cli.Wrap(executable) public async Task Refresh() { var executable = GetDatabaseExecutablePath(); - if (executable is null) - { + if(executable is null) return; - } var workingDir = GetWorkingDir(executable); var checkResult = await Cli.Wrap(executable) @@ -46,6 +42,80 @@ public async Task Refresh() DatabaseStatus = ExitCodeToDbStatus.GetDatabaseStatus(checkResult.ExitCode); } + public Task CreateSnapshot(NuGet.Versioning.SemanticVersion version) + { + if(_configurationService.Current.DatabaseProvider != DatabaseProvider.Sqlite) + return Task.CompletedTask; + + var dbPath = _configurationService.Current.SqliteDatabasePath; + if(!File.Exists(dbPath)) return Task.CompletedTask; + + var snapshotPath = GetSnapshotPath(version); + var snapshotDir = Path.GetDirectoryName(snapshotPath); + if(snapshotDir is not null) Directory.CreateDirectory(snapshotDir); + + File.Copy(dbPath, snapshotPath, true); + return Task.CompletedTask; + } + + public Task HasSnapshot(NuGet.Versioning.SemanticVersion version) + { + if(_configurationService.Current.DatabaseProvider != DatabaseProvider.Sqlite) + return Task.FromResult(false); + + var snapshotPath = GetSnapshotPath(version); + return Task.FromResult(File.Exists(snapshotPath)); + } + + public async Task RestoreSnapshot(NuGet.Versioning.SemanticVersion version) + { + try + { + if(_configurationService.Current.DatabaseProvider != DatabaseProvider.Sqlite) + return; + + var snapshotPath = GetSnapshotPath(version); + if(!File.Exists(snapshotPath)) + return; + + var dbPath = _configurationService.Current.SqliteDatabasePath; + var dbDir = Path.GetDirectoryName(dbPath); + if(dbDir is not null) + Directory.CreateDirectory(dbDir); + + File.Copy(snapshotPath, dbPath, true); + await Refresh(); + } + + catch(Exception e) + { + Console.WriteLine($"Failed to restore snapshot! {e.Message}"); + } + + } + + public Task Reset() + { + if(_configurationService.Current.DatabaseProvider == DatabaseProvider.Sqlite) + { + var dbPath = _configurationService.Current.SqliteDatabasePath; + if(File.Exists(dbPath)) + File.Delete(dbPath); + } + + DatabaseStatus = DatabaseStatus.NonExistent; + return Task.CompletedTask; + } + + private string GetSnapshotPath(NuGet.Versioning.SemanticVersion version) + { + var dbPath = _configurationService.Current.SqliteDatabasePath; + var dbDir = Path.GetDirectoryName(dbPath) ?? ""; + var fileName = Path.GetFileNameWithoutExtension(dbPath); + var extension = Path.GetExtension(dbPath); + return Path.Combine(dbDir, "Snapshots", $"{fileName}_v{version.ToNormalizedString()}{extension}.bak"); + } + private string? GetDatabaseExecutablePath() { return _configurationService.Current.InstalledAresLayout == AresReleaseLayout.UnifiedUiOnly @@ -56,7 +126,7 @@ public async Task Refresh() private static string GetWorkingDir(string path) { var workingDir = Path.GetDirectoryName(path); - if (workingDir is null) + if(workingDir is null) { workingDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); workingDir = Path.Combine(workingDir, "ARES"); diff --git a/ARESLauncher/Services/IAresDownloader.cs b/ARESLauncher/Services/IAresDownloader.cs index c774278..e101b0b 100644 --- a/ARESLauncher/Services/IAresDownloader.cs +++ b/ARESLauncher/Services/IAresDownloader.cs @@ -12,9 +12,9 @@ namespace ARESLauncher.Services; /// public interface IAresDownloader { - Task GetAvailableVersions(AresSource source, string? authToken); + Task GetAvailableVersions(AresSource source, string? authToken); - Task GetAvailableVersions(LauncherSource soruce); + Task GetAvailableVersions(LauncherSource source, string? authToken); Task Download(LauncherSource source, SemanticVersion version, string destination, string? authToken, IProgress? progress = null); @@ -24,4 +24,6 @@ Task Download(LauncherSource source, SemanticVersion version, string des /// Task Download(AresSource source, SemanticVersion version, string destination, string? authToken, IProgress? progress = null); + + void InvalidateCache(); } diff --git a/ARESLauncher/Services/IAresUpdater.cs b/ARESLauncher/Services/IAresUpdater.cs index 760fffa..e188e1c 100644 --- a/ARESLauncher/Services/IAresUpdater.cs +++ b/ARESLauncher/Services/IAresUpdater.cs @@ -13,9 +13,19 @@ public interface IAresUpdater IObservable UpdateProgress { get; } - Task GetAvailableVersions(); + Task GetAvailableVersions(); Task Update(SemanticVersion version); Task UpdateLatest(); + + Task CreateSnapshot(SemanticVersion version); + + Task HasSnapshot(SemanticVersion version); + + Task RestoreSnapshot(SemanticVersion version); + + Task ResetDatabase(); + + void InvalidateCache(); } \ No newline at end of file diff --git a/ARESLauncher/Services/IDatabaseManager.cs b/ARESLauncher/Services/IDatabaseManager.cs index 02f8097..af2166d 100644 --- a/ARESLauncher/Services/IDatabaseManager.cs +++ b/ARESLauncher/Services/IDatabaseManager.cs @@ -7,6 +7,10 @@ public interface IDatabaseManager { DatabaseStatus DatabaseStatus { get; } Task RunMigrations(); + Task CreateSnapshot(NuGet.Versioning.SemanticVersion version); + Task HasSnapshot(NuGet.Versioning.SemanticVersion version); + Task RestoreSnapshot(NuGet.Versioning.SemanticVersion version); + Task Reset(); /// /// Refreshes the status of the database as reported from the Ares Service diff --git a/ARESLauncher/Services/LauncherUpdater.cs b/ARESLauncher/Services/LauncherUpdater.cs index d72fac3..ff6b953 100644 --- a/ARESLauncher/Services/LauncherUpdater.cs +++ b/ARESLauncher/Services/LauncherUpdater.cs @@ -24,10 +24,11 @@ public LauncherUpdater(IAresDownloader downloader, ILogger logg _configurationService = configurationService; } - public Task GetAvailableVersions() + public async Task GetAvailableVersions() { var source = _configurationService.Current.LauncherSource; - return _downloader.GetAvailableVersions(source); + var releases = await _downloader.GetAvailableVersions(source, _configurationService.Current.GitToken); + return releases.Select(r => r.Version).ToArray(); } public async Task UpdateLatest() diff --git a/ARESLauncher/Tools/AresVersionRegex.cs b/ARESLauncher/Tools/AresVersionRegex.cs index 1a66d4d..2187a59 100644 --- a/ARESLauncher/Tools/AresVersionRegex.cs +++ b/ARESLauncher/Tools/AresVersionRegex.cs @@ -4,6 +4,6 @@ namespace ARESLauncher.Tools; public static partial class AresVersionRegex { - [GeneratedRegex("v([\\d.]+)")] + [GeneratedRegex("v(\\d+(?:\\.\\d+)*)")] public static partial Regex VersionRegex(); } diff --git a/ARESLauncher/Tools/Unpacker.cs b/ARESLauncher/Tools/Unpacker.cs index 78091c6..9994f53 100644 --- a/ARESLauncher/Tools/Unpacker.cs +++ b/ARESLauncher/Tools/Unpacker.cs @@ -30,10 +30,18 @@ public static Task Unpack(string compressedItem, string destinationDir) private static void ExtractArchive(string archivePath, string destinationPath) { - var extension = Path.GetExtension(archivePath); - if (!string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase)) - throw new NotSupportedException($"Unsupported archive type \"{extension}\"."); - - ZipFile.ExtractToDirectory(archivePath, destinationPath, true); + try + { + var extension = Path.GetExtension(archivePath); + if(!string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase)) + throw new NotSupportedException($"Unsupported archive type \"{extension}\"."); + + ZipFile.ExtractToDirectory(archivePath, destinationPath, true); + } + + catch(Exception ex) + { + Console.WriteLine(ex.Message); + } } } \ No newline at end of file diff --git a/ARESLauncher/Tools/VersionExtensions.cs b/ARESLauncher/Tools/VersionExtensions.cs index a872df7..846bf3a 100644 --- a/ARESLauncher/Tools/VersionExtensions.cs +++ b/ARESLauncher/Tools/VersionExtensions.cs @@ -10,24 +10,29 @@ public static class VersionExtensions public static bool IsGreatest(this SemanticVersion version, IEnumerable versionsToCheck) { var versions = versionsToCheck.ToArray(); + if(!versions.Any()) return true; var latest = versions[0]; + for(var i = 1; i < versions.Length; i++) if(versions[i] > latest) latest = versions[i]; + return version >= latest; } + public static bool IsGreatest(this SemanticVersion version, IEnumerable releasesToCheck) + => version.IsGreatest(releasesToCheck.Select(r => r.Version)); + + public static SemanticVersion GetVersionFromZipName(string fileName) { var match = AresVersionRegex.VersionRegex().Match(fileName); if(match.Success && Version.TryParse(match.Groups[1].Value, out var v)) - { return new SemanticVersion(v.Major, v.Minor, v.Build); - } throw new ArgumentException($"Invalid version in filename: {fileName}"); } diff --git a/ARESLauncher/ViewModels/ConfigurationEditorViewModel.cs b/ARESLauncher/ViewModels/ConfigurationEditorViewModel.cs index f2b15f1..cec3a27 100644 --- a/ARESLauncher/ViewModels/ConfigurationEditorViewModel.cs +++ b/ARESLauncher/ViewModels/ConfigurationEditorViewModel.cs @@ -1,6 +1,7 @@ using ARESLauncher.Models; using ARESLauncher.Services; using ARESLauncher.Services.Configuration; +using NuGet.Versioning; using ReactiveUI; using ReactiveUI.SourceGenerators; using System; @@ -9,6 +10,7 @@ using System.Linq; using System.Reactive; using System.Reactive.Linq; +using System.Threading.Tasks; namespace ARESLauncher.ViewModels; @@ -16,13 +18,20 @@ public partial class ConfigurationEditorViewModel : ViewModelBase { private readonly IAppConfigurationService _configurationService; private readonly IAppSettingsUpdater _appSettingsUpdater; + private readonly IAresUpdater _aresUpdater; + private readonly IAresBinaryManager _aresBinaryManager; private readonly IReadOnlyList _databaseProviders; private readonly IReadOnlyList _releaseLayouts; - public ConfigurationEditorViewModel(IAppConfigurationService configurationService, IAppSettingsUpdater appSettingsUpdater) + public ConfigurationEditorViewModel(IAppConfigurationService configurationService, + IAppSettingsUpdater appSettingsUpdater, + IAresUpdater aresUpdater, + IAresBinaryManager aresBinaryManager) { _configurationService = configurationService ?? throw new ArgumentNullException(nameof(configurationService)); _appSettingsUpdater = appSettingsUpdater; + _aresUpdater = aresUpdater; + _aresBinaryManager = aresBinaryManager; _databaseProviders = Enum.GetValues(); _releaseLayouts = Enum.GetValues(); @@ -39,6 +48,13 @@ public ConfigurationEditorViewModel(IAppConfigurationService configurationServic EditableAresServiceProcessName = string.Empty; EditableAresUiProcessName = string.Empty; EditableInstalledAresLayout = AresReleaseLayout.SplitUiAndService; + + var canInstall = this.WhenAnyValue( + x => x.SelectedRelease, + x => x.UpdateInProgress, + (selected, inProgress) => selected != null && !selected.IsInstalled && !inProgress); + UpdateAresCommand = ReactiveCommand.CreateFromTask(UpdateAres, canInstall); + LoadEditableConfiguration(); AddRepositoryCommand = ReactiveCommand.Create(AddRepository); @@ -49,12 +65,30 @@ public ConfigurationEditorViewModel(IAppConfigurationService configurationServic } public event EventHandler? ConfigurationSaved; + public Interaction UpdateConfirmationDialog { get; } = new(); public IReadOnlyList DatabaseProviders => _databaseProviders; public IReadOnlyList ReleaseLayouts => _releaseLayouts; public ObservableCollection AvailableRepositories { get; } + [Reactive] + public partial AresRelease[]? AvailableReleases { get; private set; } + + [Reactive] + public partial AresRelease? SelectedRelease { get; set; } + + [Reactive] + public partial string? InstalledAresVersion { get; private set; } + + public ReactiveCommand UpdateAresCommand { get; } + + [Reactive] + public partial bool UpdateInProgress { get; private set; } + + [Reactive] + public partial string? UpdateError { get; private set; } + [Reactive] public partial AresSourceEditorViewModel? SelectedAvailableRepository { get; set; } @@ -100,6 +134,9 @@ public ConfigurationEditorViewModel(IAppConfigurationService configurationServic [Reactive] public partial AresReleaseLayout EditableInstalledAresLayout { get; set; } + [Reactive] + public partial bool EditableIncludeBeta { get; set; } + [Reactive] public partial bool ShowAdvancedOptions { get; set; } @@ -165,6 +202,7 @@ private void SaveConfiguration() configuration.AresServiceProcessName = EditableAresServiceProcessName; configuration.AresUiProcessName = EditableAresUiProcessName; configuration.InstalledAresLayout = EditableInstalledAresLayout; + configuration.IncludeBeta = EditableIncludeBeta; var validRepositories = AvailableRepositories .Where(IsValidRepository) @@ -213,6 +251,7 @@ private void LoadEditableConfiguration() EditableAresServiceProcessName = current.AresServiceProcessName; EditableAresUiProcessName = current.AresUiProcessName; EditableInstalledAresLayout = current.InstalledAresLayout; + EditableIncludeBeta = current.IncludeBeta; AvailableRepositories.Clear(); foreach(var repo in current.AvailableAresRepos) @@ -225,6 +264,86 @@ private void LoadEditableConfiguration() string.Equals(repo.Owner, current.CurrentAresRepo.Owner, StringComparison.OrdinalIgnoreCase) && string.Equals(repo.Repo, current.CurrentAresRepo.Repo, StringComparison.OrdinalIgnoreCase)) ?? AvailableRepositories.FirstOrDefault(); + + _ = RefreshReleases(); + } + + private async Task RefreshReleases() + { + await _aresBinaryManager.Refresh(); + var currentVersion = _aresBinaryManager.CurrentVersion; + InstalledAresVersion = currentVersion?.ToNormalizedString(); + + var releases = await _aresUpdater.GetAvailableVersions(); + foreach(var release in releases) + { + release.IsInstalled = release.Version.Equals(currentVersion); + } + + AvailableReleases = releases; + SelectedRelease = AvailableReleases.FirstOrDefault(r => r.IsInstalled) ?? AvailableReleases.FirstOrDefault(); + } + + private async Task UpdateAres() + { + if(SelectedRelease is null) return; + + var currentVersion = _aresBinaryManager.CurrentVersion; + var targetVersion = SelectedRelease.Version; + + if(RequiresUpdateConfirmation(currentVersion, targetVersion)) + { + var isDowngrade = currentVersion is not null && targetVersion < currentVersion; + var hasSnapshot = isDowngrade && await _aresUpdater.HasSnapshot(targetVersion); + + var response = await UpdateConfirmationDialog.Handle(new UpdateConfirmationRequest + { + CurrentVersion = currentVersion ?? targetVersion, + TargetVersion = targetVersion, + HasSnapshot = hasSnapshot + }); + + if(!response.ShouldProceed) return; + + // Take snapshot of current version before doing anything + if(currentVersion is not null) + { + await _aresUpdater.CreateSnapshot(currentVersion); + } + + if(response.DowngradeOption == DowngradeOption.RestoreSnapshot) + { + await _aresUpdater.RestoreSnapshot(targetVersion); + } + else if(response.DowngradeOption == DowngradeOption.Reset) + { + await _aresUpdater.ResetDatabase(); + } + } + + try + { + UpdateInProgress = true; + UpdateError = null; + await _aresUpdater.Update(targetVersion); + } + catch(Exception e) + { + UpdateError = e.Message; + } + finally + { + UpdateInProgress = false; + await RefreshReleases(); + } + } + + private static bool RequiresUpdateConfirmation(SemanticVersion? currentVersion, SemanticVersion? targetVersion) + { + if(currentVersion is null || targetVersion is null) return false; + if(targetVersion < currentVersion) return true; + if(targetVersion.Major > currentVersion.Major) return true; + return targetVersion.Major == currentVersion.Major && targetVersion.Minor > currentVersion.Minor; } private static bool IsValidRepository(AresSourceEditorViewModel repo) diff --git a/ARESLauncher/ViewModels/MainViewModel.cs b/ARESLauncher/ViewModels/MainViewModel.cs index 20b181b..1994fc7 100644 --- a/ARESLauncher/ViewModels/MainViewModel.cs +++ b/ARESLauncher/ViewModels/MainViewModel.cs @@ -17,7 +17,6 @@ namespace ARESLauncher.ViewModels; public partial class MainViewModel : ViewModelBase { - private readonly IAppSettingsUpdater _appSettingsUpdater; private readonly IAresBinaryManager _aresBinaryManager; private readonly ObservableAsPropertyHelper _aresComponentsRunning; private readonly IAresStarter _aresStarter; @@ -29,7 +28,6 @@ public partial class MainViewModel : ViewModelBase private readonly ObservableAsPropertyHelper _auxButtonContent; private readonly ObservableAsPropertyHelper _buttonCommand; private readonly ObservableAsPropertyHelper _buttonText; - private readonly ICertificateManager _certificateManager; private readonly IConflictManager _conflictManager; private readonly ObservableAsPropertyHelper _currentUpdateStep; private readonly IDatabaseManager _databaseManager; @@ -48,19 +46,17 @@ public MainViewModel(ConfigurationOverviewViewModel overview, IAresBinaryManager aresBinaryManager, IAresStarter aresStarter, IAppSettingsUpdater appSettingsUpdater, - ICertificateManager certificateManager, IAresUpdater aresUpdater, ILauncherUpdater launcherUpdater, IDatabaseManager databaseManager, IBrowserOpener browserOpener, IConflictManager conflictManager) { + AvailableAresVersions = []; Overview = overview ?? throw new ArgumentNullException(nameof(overview)); Editor = editor ?? throw new ArgumentNullException(nameof(editor)); _aresBinaryManager = aresBinaryManager; _aresStarter = aresStarter; - _appSettingsUpdater = appSettingsUpdater; - _certificateManager = certificateManager; _aresUpdater = aresUpdater; _launcherUpdater = launcherUpdater; _databaseManager = databaseManager; @@ -81,7 +77,7 @@ public MainViewModel(ConfigurationOverviewViewModel overview, UpdateAresCommand = ReactiveCommand.CreateFromTask(UpdateAres); OpenBrowserCommand = ReactiveCommand.Create(browserOpener.Open); OpenLauncherReleasePageCommand = ReactiveCommand.CreateFromTask(CheckForUpdatedLauncher); - UpdateConfirmationDialog = new Interaction(); + UpdateConfirmationDialog = new Interaction(); ConflictDialog = new Interaction(); ResolveConflictsCommand = ReactiveCommand.CreateFromTask(async () => { @@ -103,15 +99,29 @@ public MainViewModel(ConfigurationOverviewViewModel overview, _currentUpdateStep = _aresUpdater.CurrentUpdateStep.ToProperty(this, vm => vm.CurrentUpdateStep); _progress = _aresUpdater.UpdateProgress.ToProperty(this, vm => vm.Progress); + this.WhenAnyValue(x => x.Editor.UpdateInProgress) + .Skip(1) + .Subscribe((bool inProgress) => + { + if(inProgress == false) + _ = this.CheckAresCondition(); + }); + _aresComponentsRunning = _aresStarter .AresUiRunning .CombineLatest(_aresStarter.AresServiceRunning, (ui, service) => (ui ? 1 : 0) + (service ? 1 : 0)) .ToProperty(this, vm => vm.AresComponentsRunning); _updateAvailable = this - .WhenAnyValue(x => x.AvailableVersions, x => x.AresComponentsRunning, x => x.CurrentUpdateStep, (av, runnin, updateStep) => + .WhenAnyValue(x => x.AvailableAresUpdateVersion, x => x.AresComponentsRunning, x => x.CurrentUpdateStep, x => x.InstalledAresVersion, (av, runnin, updateStep, installedAresVersion) => { - bool updateAvailable = av is not null && _aresBinaryManager.CurrentVersion is not null && !_aresBinaryManager.CurrentVersion.IsGreatest(av); + if(string.IsNullOrEmpty(av) || _aresBinaryManager.CurrentVersion is null) + return false; + + if(!SemanticVersion.TryParse(av, out var latest)) + return false; + + bool updateAvailable = latest > _aresBinaryManager.CurrentVersion; return updateAvailable && runnin == 0 && updateStep == UpdateStep.Idle; }) .ToProperty(this, vm => vm.UpdateAvailable); @@ -132,9 +142,9 @@ public MainViewModel(ConfigurationOverviewViewModel overview, vm => vm.AresPresent, vm => vm.DatabaseStatus, vm => vm.CurrentUpdateStep, - (isRunning, isPresent, dbStatus, updootStep) => + (isRunning, isPresent, dbStatus, updateStep) => { - if(updootStep != UpdateStep.Idle) + if(updateStep != UpdateStep.Idle) { return AresState.Updating; } @@ -144,54 +154,51 @@ public MainViewModel(ConfigurationOverviewViewModel overview, var partiallyRunning = layout == AresReleaseLayout.SplitUiAndService && isRunning == 1; if(partiallyRunning) - { return AresState.OneRunning; - } + if(fullyRunning) - { return AresState.BothRunning; - } if(!isPresent) - { return AresState.NeedsInstall; - } if(dbStatus != DatabaseStatus.UpToDate) - { return AresState.NeedsDbUpdate; - } return AresState.Ready; }).ToProperty(this, vm => vm.AresState); _buttonText = this - .WhenAnyValue(vm => vm.AresState) - .Select(s => s switch + .WhenAnyValue(vm => vm.AresState, (s) => { - AresState.Unknown => ":)", - AresState.OneRunning => "Start", - AresState.BothRunning => "Stop", - AresState.Ready => "Start", - AresState.NeedsDbUpdate => "Update DB", - AresState.NeedsInstall => "Install", - AresState.Updating => "Updating...", - _ => throw new NotImplementedException() + return s switch + { + AresState.Unknown => ":)", + AresState.OneRunning => "Start", + AresState.BothRunning => "Stop", + AresState.Ready => "Start", + AresState.NeedsDbUpdate => "Update DB", + AresState.NeedsInstall => "Install", + AresState.Updating => "Updating...", + _ => throw new NotImplementedException() + }; }).ToProperty(this, vm => vm.ButtonText); _buttonCommand = this - .WhenAnyValue(vm => vm.AresState) - .Select(s => s switch + .WhenAnyValue(vm => vm.AresState, (s) => { - AresState.Unknown => null, - AresState.OneRunning => StartAresCommand, - AresState.BothRunning => StopAresCommand, - AresState.Ready => StartAresCommand, - AresState.NeedsDbUpdate => UpdateDatabaseCommand, - AresState.NeedsInstall => UpdateAresCommand, - AresState.Updating => null, - _ => throw new NotImplementedException() + return s switch + { + AresState.Unknown => null, + AresState.OneRunning => StartAresCommand, + AresState.BothRunning => StopAresCommand, + AresState.Ready => StartAresCommand, + AresState.NeedsDbUpdate => UpdateDatabaseCommand, + AresState.NeedsInstall => UpdateAresCommand, + AresState.Updating => null, + _ => throw new NotImplementedException() + }; }).ToProperty(this, vm => vm.ButtonCommand); _auxButtonContent = this @@ -237,14 +244,8 @@ public MainViewModel(ConfigurationOverviewViewModel overview, }).ToProperty(this, vm => vm.AresStateDescription); _updateInProgress = this - .WhenAnyValue(vm => vm.CurrentUpdateStep) - .Select(s => s switch - { - UpdateStep.Idle => false, - UpdateStep.Downloading => true, - UpdateStep.Other => true, - _ => throw new NotImplementedException() - }).ToProperty(this, vm => vm.UpdateInProgress); + .WhenAnyValue(vm => vm.InstalledAresVersion) + .Select(IsLatestAresVersion).ToProperty(this, vm => vm.UpdateInProgress); _showProgressBar = this .WhenAnyValue(vm => vm.CurrentUpdateStep) @@ -264,6 +265,9 @@ public MainViewModel(ConfigurationOverviewViewModel overview, RefreshCommand.Execute(); } + private bool IsLatestAresVersion(string version) + => AvailableAresVersions.FirstOrDefault()?.Version.ToNormalizedString() == InstalledAresVersion; + [Reactive] public partial bool AresConditionChecked { get; private set; } @@ -320,22 +324,21 @@ public MainViewModel(ConfigurationOverviewViewModel overview, public bool UpdateInProgress => _updateInProgress.Value; - public bool UpdateAvailable => _updateAvailable.Value; + public bool UpdateAvailable => _updateAvailable.Value; public bool LauncherUpdateAvailable => _launcherUpdateAvailable.Value; + public AresRelease[] AvailableAresVersions { get; set; } + private async Task UpdateAvailableVersions() { await _aresBinaryManager.Refresh(); InstalledAresVersion = _aresBinaryManager.CurrentVersion?.ToNormalizedString() ?? string.Empty; - AvailableVersions = await _aresUpdater.GetAvailableVersions(); - AvailableAresUpdateVersion = AvailableVersions?.OrderDescending().FirstOrDefault()?.ToNormalizedString() ?? string.Empty; + AvailableAresVersions = await _aresUpdater.GetAvailableVersions(); + AvailableAresUpdateVersion = AvailableAresVersions?.FirstOrDefault()?.Version.ToNormalizedString() ?? string.Empty; AvailableLauncherVersions = await _launcherUpdater.GetAvailableVersions(); } - [Reactive] - public partial SemanticVersion[]? AvailableVersions { get; private set; } - [Reactive] public partial SemanticVersion[]? AvailableLauncherVersions { get; private set; } @@ -358,7 +361,7 @@ private async Task UpdateAvailableVersions() public ReactiveCommand CheckForUpdate { get; } public Interaction ConflictDialog { get; } - public Interaction UpdateConfirmationDialog { get; } + public Interaction UpdateConfirmationDialog { get; } public bool ShowProgressBar => _showProgressBar.Value; public ConflictResolutionDialogViewModel GetConflictResolutionDialogViewModel() @@ -373,49 +376,73 @@ private async Task CheckAresCondition() await _aresBinaryManager.Refresh(); InstalledAresVersion = _aresBinaryManager.CurrentVersion?.ToNormalizedString() ?? string.Empty; AresPresent = _aresBinaryManager.CurrentVersion is not null; + + AvailableAresVersions = await _aresUpdater.GetAvailableVersions(); + AvailableAresUpdateVersion = AvailableAresVersions?.FirstOrDefault()?.Version.ToNormalizedString() ?? string.Empty; + AvailableLauncherVersions = await _launcherUpdater.GetAvailableVersions(); + if(!AresPresent) { AresConditionChecked = true; return; } - AvailableVersions = await _aresUpdater.GetAvailableVersions(); - AvailableAresUpdateVersion = AvailableVersions?.OrderDescending().FirstOrDefault()?.ToNormalizedString() ?? string.Empty; - AvailableLauncherVersions = await _launcherUpdater.GetAvailableVersions(); - await _databaseManager.Refresh(); DatabaseStatus = _databaseManager.DatabaseStatus; if(DatabaseStatus != DatabaseStatus.UpToDate) { AresConditionChecked = true; + this.RaisePropertyChanged(nameof(UpdateAvailable)); return; } AresConditionChecked = true; + this.RaisePropertyChanged(nameof(UpdateAvailable)); } private async Task UpdateAres() { var currentVersion = _aresBinaryManager.CurrentVersion; - var targetVersion = AvailableVersions?.OrderDescending().FirstOrDefault(); + AvailableAresVersions = await _aresUpdater.GetAvailableVersions(); + var latest = AvailableAresVersions.FirstOrDefault(); + + if(latest is null) + return; + + var targetVersion = latest.Version; + if(RequiresUpdateConfirmation(currentVersion, targetVersion)) { - var shouldProceed = await UpdateConfirmationDialog.Handle(new UpdateConfirmationRequest + var isDowngrade = currentVersion is not null && targetVersion < currentVersion; + var hasSnapshot = isDowngrade && await _aresUpdater.HasSnapshot(targetVersion); + + var response = await UpdateConfirmationDialog.Handle(new UpdateConfirmationRequest { - CurrentVersion = currentVersion!, - TargetVersion = targetVersion! + CurrentVersion = currentVersion ?? targetVersion, + TargetVersion = targetVersion, + HasSnapshot = hasSnapshot }); - if(!shouldProceed) - { + if(!response.ShouldProceed) return; - } + + // Take snapshot of current version before doing anything + if(currentVersion is not null) + await _aresUpdater.CreateSnapshot(currentVersion); + + if(response.DowngradeOption == DowngradeOption.RestoreSnapshot) + await _aresUpdater.RestoreSnapshot(targetVersion); + + else if(response.DowngradeOption == DowngradeOption.Reset) + await _aresUpdater.ResetDatabase(); + + await _aresBinaryManager.Refresh(); } try { Error = ""; - await _aresUpdater.UpdateLatest(); + await _aresUpdater.Update(targetVersion); } catch(Exception e) { @@ -441,14 +468,10 @@ private async Task CheckForUpdatedLauncher() } if(Application.Current is App app) - { app.BeginShutdown(); - } if(Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) - { desktopLifetime.Shutdown(); - } } catch(Exception e) @@ -482,7 +505,7 @@ private void OnConfigurationSaved(object? sender, EventArgs e) } // Check if we should ask for confirmation. We should ask if there's a major/minor update - // Let's ignore patches as those should not break things... should + // Or if it's a downgrade private static bool RequiresUpdateConfirmation(SemanticVersion? currentVersion, SemanticVersion? targetVersion) { if(currentVersion is null || targetVersion is null) @@ -490,6 +513,11 @@ private static bool RequiresUpdateConfirmation(SemanticVersion? currentVersion, return false; } + if(targetVersion < currentVersion) + { + return true; + } + if(targetVersion.Major > currentVersion.Major) { return true; diff --git a/ARESLauncher/ViewModels/UpdateConfirmationDialogViewModel.cs b/ARESLauncher/ViewModels/UpdateConfirmationDialogViewModel.cs index c29b4f9..87e56e9 100644 --- a/ARESLauncher/ViewModels/UpdateConfirmationDialogViewModel.cs +++ b/ARESLauncher/ViewModels/UpdateConfirmationDialogViewModel.cs @@ -11,22 +11,34 @@ public UpdateConfirmationDialogViewModel(UpdateConfirmationRequest request) CurrentVersion = request.CurrentVersion.ToNormalizedString(); TargetVersion = request.TargetVersion.ToNormalizedString(); - MajorUpdate = request.TargetVersion.Major > request.CurrentVersion.Major; + MajorUpdate = request.TargetVersion > request.CurrentVersion && request.TargetVersion.Major > request.CurrentVersion.Major; + IsDowngrade = request.TargetVersion < request.CurrentVersion; + HasSnapshot = request.HasSnapshot; - ProceedCommand = ReactiveCommand.Create(() => Unit.Default); - CancelCommand = ReactiveCommand.Create(() => Unit.Default); + ProceedCommand = ReactiveCommand.Create(() => UpdateConfirmationResponse.Proceed()); + CancelCommand = ReactiveCommand.Create(() => UpdateConfirmationResponse.Cancel); + RestoreSnapshotCommand = ReactiveCommand.Create(() => UpdateConfirmationResponse.Proceed(DowngradeOption.RestoreSnapshot)); + ResetCommand = ReactiveCommand.Create(() => UpdateConfirmationResponse.Proceed(DowngradeOption.Reset)); } public string CurrentVersion { get; } public string TargetVersion { get; } public bool MajorUpdate { get; } + public bool IsDowngrade { get; } + public bool HasSnapshot { get; } public string Message => - MajorUpdate - ? $"This will update ARES from {CurrentVersion} to {TargetVersion}.\nThis is a major update and we recommend backing up your database as there is potential of data loss." - : $"This will update ARES from {CurrentVersion} to {TargetVersion}.\nWhile this is a minor update, we would still recommend backing up your database just to be safe."; + IsDowngrade + ? HasSnapshot + ? $"You are downgrading ARES from {CurrentVersion} to {TargetVersion}.\nA database snapshot for version {TargetVersion} was found. Would you like to restore it?" + : $"You are downgrading ARES from {CurrentVersion} to {TargetVersion}.\nNo database snapshot for version {TargetVersion} was found. Your database will be reset to avoid compatibility issues." + : MajorUpdate + ? $"This will update ARES from {CurrentVersion} to {TargetVersion}.\nThis is a major update and we recommend backing up your database as there is potential of data loss." + : $"This will update ARES from {CurrentVersion} to {TargetVersion}.\nWhile this is a minor update, we would still recommend backing up your database just to be safe."; - public ReactiveCommand ProceedCommand { get; } - public ReactiveCommand CancelCommand { get; } + public ReactiveCommand ProceedCommand { get; } + public ReactiveCommand CancelCommand { get; } + public ReactiveCommand RestoreSnapshotCommand { get; } + public ReactiveCommand ResetCommand { get; } } diff --git a/ARESLauncher/Views/ConfigurationEditorView.axaml b/ARESLauncher/Views/ConfigurationEditorView.axaml index 948f069..d08d44a 100644 --- a/ARESLauncher/Views/ConfigurationEditorView.axaml +++ b/ARESLauncher/Views/ConfigurationEditorView.axaml @@ -3,12 +3,62 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:ARESLauncher.ViewModels" + xmlns:models="clr-namespace:ARESLauncher.Models" + xmlns:ic="using:FluentIcons.Avalonia" mc:Ignorable="d" x:Class="ARESLauncher.Views.ConfigurationEditorView" x:DataType="vm:ConfigurationEditorViewModel"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto"> + + + - - - - - - - - - - - - - - - - - - - diff --git a/ARESLauncher/Views/ConfigurationEditorView.axaml.cs b/ARESLauncher/Views/ConfigurationEditorView.axaml.cs index 7af4427..8c1a1ee 100644 --- a/ARESLauncher/Views/ConfigurationEditorView.axaml.cs +++ b/ARESLauncher/Views/ConfigurationEditorView.axaml.cs @@ -1,11 +1,48 @@ +using ARESLauncher.Models; +using ARESLauncher.ViewModels; using Avalonia.Controls; +using ReactiveUI; +using System; +using System.Threading.Tasks; namespace ARESLauncher.Views; -public partial class ConfigurationEditorView : UserControl +public partial class ConfigurationEditorView : UserControl, IActivatableView { public ConfigurationEditorView() { InitializeComponent(); + this.WhenActivated(d => + { + if(DataContext is ConfigurationEditorViewModel viewModel) + d(viewModel.UpdateConfirmationDialog.RegisterHandler(DoShowUpdateConfirmationDialog)); + + }); + } + + private async Task DoShowUpdateConfirmationDialog(InteractionContext context) + { + var dialogVm = new UpdateConfirmationDialogViewModel(context.Input); + var dialog = new UpdateConfirmationDialog + { + DataContext = dialogVm + }; + + var closeAndSet = new Action(dialog.Close); + + dialogVm.ProceedCommand.Subscribe(closeAndSet); + dialogVm.CancelCommand.Subscribe(closeAndSet); + dialogVm.RestoreSnapshotCommand.Subscribe(closeAndSet); + dialogVm.ResetCommand.Subscribe(closeAndSet); + + var topLevel = TopLevel.GetTopLevel(this); + if(topLevel is Window window) + { + var result = await dialog.ShowDialog(window); + context.SetOutput(result ?? UpdateConfirmationResponse.Cancel); + } + + else + context.SetOutput(UpdateConfirmationResponse.Proceed()); } } diff --git a/ARESLauncher/Views/MainView.axaml b/ARESLauncher/Views/MainView.axaml index 319c813..0fad3ad 100644 --- a/ARESLauncher/Views/MainView.axaml +++ b/ARESLauncher/Views/MainView.axaml @@ -6,6 +6,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:ARESLauncher.ViewModels" + xmlns:models="clr-namespace:ARESLauncher.Models" xmlns:views="clr-namespace:ARESLauncher.Views" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="ARESLauncher.Views.MainView" @@ -36,17 +37,17 @@ - + - - + diff --git a/ARESLauncher/Views/MainWindow.axaml.cs b/ARESLauncher/Views/MainWindow.axaml.cs index 5de4d91..0dc3027 100644 --- a/ARESLauncher/Views/MainWindow.axaml.cs +++ b/ARESLauncher/Views/MainWindow.axaml.cs @@ -1,6 +1,7 @@ using System; using System.Reactive; using System.Reactive.Linq; +using ARESLauncher.Models; using ARESLauncher.ViewModels; using Avalonia; using Avalonia.Controls; @@ -47,11 +48,15 @@ protected override void OnDataContextChanged(EventArgs e) DataContext = dialogVm }; - dialogVm.ProceedCommand.Subscribe(_ => dialog.Close(true)); - dialogVm.CancelCommand.Subscribe(_ => dialog.Close(false)); + var closeAndSet = new Action(res => dialog.Close(res)); - var result = await dialog.ShowDialog(this); - interaction.SetOutput(result); + dialogVm.ProceedCommand.Subscribe(closeAndSet); + dialogVm.CancelCommand.Subscribe(closeAndSet); + dialogVm.RestoreSnapshotCommand.Subscribe(closeAndSet); + dialogVm.ResetCommand.Subscribe(closeAndSet); + + var result = await dialog.ShowDialog(this); + interaction.SetOutput(result ?? UpdateConfirmationResponse.Cancel); }); } diff --git a/ARESLauncher/Views/UpdateConfirmationDialog.axaml b/ARESLauncher/Views/UpdateConfirmationDialog.axaml index 299eee1..c044e13 100644 --- a/ARESLauncher/Views/UpdateConfirmationDialog.axaml +++ b/ARESLauncher/Views/UpdateConfirmationDialog.axaml @@ -22,11 +22,13 @@ TextWrapping="Wrap" TextAlignment="Center" /> + TextAlignment="Center" + IsVisible="{Binding !IsDowngrade}"/> + Margin="0,8,0,0" + IsVisible="{Binding !IsDowngrade}">