diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index aa210483a0..dd589f5e24 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -862,6 +862,7 @@ "Do not automatically install updates when the network connection is metered": "Do not automatically install updates when the network connection is metered", "Do not automatically install updates when the device runs on battery": "Do not automatically install updates when the device runs on battery", "Do not automatically install updates when the battery saver is on": "Do not automatically install updates when the battery saver is on", + "Quit UniGetUI once automatic updates have finished, if its window is not open": "Quit UniGetUI once automatic updates have finished, if its window is not open", "Only show updates that are at least the specified number of days old": "Only show updates that are at least the specified number of days old", "Warn me when the installer URL host changes between the installed version and the new version (WinGet only)": "Warn me when the installer URL host changes between the installed version and the new version (WinGet only)", "Change how UniGetUI handles install, update and uninstall operations.": "Change how UniGetUI handles install, update and uninstall operations.", @@ -1203,6 +1204,8 @@ "Never": "Never", "Scheduled maintenance": "Scheduled maintenance", "Battery and metered connection restrictions, minimum update age, etc.": "Battery and metered connection restrictions, minimum update age, etc.", + "Minimum update age, installer host change warnings, etc.": "Minimum update age, installer host change warnings, etc.", + "Choose when updates are checked and installed, and when automatic installs are skipped or UniGetUI quits": "Choose when updates are checked and installed, and when automatic installs are skipped or UniGetUI quits", "Backup destination, file name and cloud backup account.": "Backup destination, file name and cloud backup account.", "Choose the days and hours when updates are checked and installed, and when backups are made": "Choose the days and hours when updates are checked and installed, and when backups are made", "Choose the days and hours when the backups are performed.": "Choose the days and hours when the backups are performed.", diff --git a/src/UniGetUI.Avalonia/Infrastructure/AutomaticQuitMonitor.cs b/src/UniGetUI.Avalonia/Infrastructure/AutomaticQuitMonitor.cs new file mode 100644 index 0000000000..f616351b84 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/AutomaticQuitMonitor.cs @@ -0,0 +1,75 @@ +namespace UniGetUI.Avalonia.Infrastructure; + +public readonly record struct AutomaticQuitSnapshot( + bool IsBusy, + bool IsEnabled, + bool IsAlreadyQuitting, + bool IsWindowVisible, + bool HasUnsavedWork); + +public enum AutomaticQuitDecision +{ + KeepWaiting, + Quit, + Cancelled, + WindowVisible, + UnsavedWork, +} + +public static class AutomaticQuitMonitor +{ + public const int RequiredIdleChecks = 2; + + public static AutomaticQuitDecision Evaluate(AutomaticQuitSnapshot snapshot, ref int idleChecks) + { + if (snapshot.IsBusy) + { + idleChecks = 0; + return AutomaticQuitDecision.KeepWaiting; + } + + if (++idleChecks < RequiredIdleChecks) + return AutomaticQuitDecision.KeepWaiting; + + if (!snapshot.IsEnabled || snapshot.IsAlreadyQuitting) + return AutomaticQuitDecision.Cancelled; + + if (snapshot.IsWindowVisible) + return AutomaticQuitDecision.WindowVisible; + + if (snapshot.HasUnsavedWork) + return AutomaticQuitDecision.UnsavedWork; + + return AutomaticQuitDecision.Quit; + } + + public static async Task RunAsync( + Task? launching, + Func waitForNextSample, + Func, Task> runOnUiThread, + Func sample, + Action quit) + { + if (launching is not null) + await launching; + + int idleChecks = 0; + while (true) + { + await waitForNextSample(); + AutomaticQuitDecision decision = await runOnUiThread(() => + { + if (sample() is not { } snapshot) + return AutomaticQuitDecision.Cancelled; + + AutomaticQuitDecision result = Evaluate(snapshot, ref idleChecks); + if (result is AutomaticQuitDecision.Quit) + quit(); + return result; + }); + + if (decision is not AutomaticQuitDecision.KeepWaiting) + return decision; + } + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index 917d61fd05..36d580217f 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -379,8 +379,39 @@ public static async Task UpdateCheckLoopAsync() } } + private static int _activeAttempts; + private static int _attemptsAwaitingConsent; + private static readonly InstallerHandoffTracker InstallerHandoffs = new(); + private static readonly TimeSpan InstallerHandoffTimeout = TimeSpan.FromMinutes(2); + + internal static bool IsBusy + => Volatile.Read(ref _activeAttempts) > Volatile.Read(ref _attemptsAwaitingConsent); + + internal static async Task WaitForInstallerHandoffAsync() + { + if (!InstallerHandoffs.HasPending) + return; + + LogUpdateInfo("Auto-updater: waiting for the ready update to be handed off before shutting down."); + if (!await InstallerHandoffs.WaitForAllAsync(InstallerHandoffTimeout)) + LogUpdateWarn($"Auto-updater: the ready update was not handed off within {InstallerHandoffTimeout.TotalMinutes:0} minutes, shutting down anyway."); + } + // ------------------------------------------------------------------ core logic internal static async Task CheckAndInstallUpdatesAsync(bool autoLaunch = false, bool manualCheck = false) + { + Interlocked.Increment(ref _activeAttempts); + try + { + return await CheckAndInstallUpdatesCoreAsync(autoLaunch, manualCheck); + } + finally + { + Interlocked.Decrement(ref _activeAttempts); + } + } + + private static async Task CheckAndInstallUpdatesCoreAsync(bool autoLaunch, bool manualCheck) { ResetUpdateLog(manualCheck, autoLaunch); UpdaterOverrides overrides = LoadUpdaterOverrides(); @@ -638,24 +669,40 @@ private static async Task PrepareAndLaunchAsync( // On first launch in background we wait for user interaction } - // Wait until user requests install, clicks the toast, or the window is being closed - while (!_installRequested && !ReleaseLockForAutoupdate_Window && !ReleaseLockForAutoupdate_Notification) + TaskCompletionSource handoff = InstallerHandoffs.Begin(); + try { - if (!manualCheck && Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) + // Wait until user requests install, clicks the toast, or the window is being closed + Interlocked.Increment(ref _attemptsAwaitingConsent); + try { - LogUpdateWarn("Auto-updater: disabled while waiting for user \u2014 aborting."); - MarkAttemptFinished("aborted - auto-update disabled while waiting"); - return true; + while (!_installRequested && !ReleaseLockForAutoupdate_Window && !ReleaseLockForAutoupdate_Notification) + { + if (!manualCheck && Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) + { + LogUpdateWarn("Auto-updater: disabled while waiting for user \u2014 aborting."); + MarkAttemptFinished("aborted - auto-update disabled while waiting"); + return true; + } + await Task.Delay(500); + } + } + finally + { + Interlocked.Decrement(ref _attemptsAwaitingConsent); } - await Task.Delay(500); - } - LogUpdateInfo("Installing update \u2014 launching installer."); - await LaunchInstallerAsync(installerPath); - return true; + LogUpdateInfo("Installing update \u2014 launching installer."); + await LaunchInstallerAsync(installerPath, handoff); + return true; + } + finally + { + InstallerHandoffs.Complete(handoff); + } } - private static async Task LaunchInstallerAsync(string installerLocation) + private static async Task LaunchInstallerAsync(string installerLocation, TaskCompletionSource handoff) { if (OperatingSystem.IsMacOS()) { @@ -732,6 +779,7 @@ private static async Task LaunchInstallerAsync(string installerLocation) } LogUpdateInfo($"Installer process started (PID {p.Id}). The installer is expected to terminate UniGetUI before file replacement."); + InstallerHandoffs.Complete(handoff); RaiseStatus( CoreTools.Translate("UniGetUI is being updated..."), diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs index c49c3f5d7d..0c5b6f7a3d 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs @@ -315,10 +315,7 @@ private static async Task RunPostOperationChecksAsync() // Let all remaining operations settle before making decisions await Task.Delay(500); - bool anyStillRunning = Operations.Any( - o => o.Status is OperationStatus.Running or OperationStatus.InQueue); - - if (!anyStillRunning) + if (!HasActiveOperations()) { var unknownShortcuts = GetPendingDesktopShortcuts(); @@ -337,6 +334,9 @@ private static async Task RunPostOperationChecksAsync() } } + public static bool HasActiveOperations() + => Operations.Any(o => o.Status is OperationStatus.Running or OperationStatus.InQueue); + public static void PromptPendingShortcutsIfAny() { if (!OperatingSystem.IsWindows()) return; diff --git a/src/UniGetUI.Avalonia/Infrastructure/InstallerHandoffTracker.cs b/src/UniGetUI.Avalonia/Infrastructure/InstallerHandoffTracker.cs new file mode 100644 index 0000000000..2077d914d6 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/InstallerHandoffTracker.cs @@ -0,0 +1,51 @@ +namespace UniGetUI.Avalonia.Infrastructure; + +public sealed class InstallerHandoffTracker +{ + private readonly object _lock = new(); + private readonly HashSet _pending = []; + + public bool HasPending + { + get + { + lock (_lock) + return _pending.Count > 0; + } + } + + public TaskCompletionSource Begin() + { + var handoff = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lock (_lock) + _pending.Add(handoff); + return handoff; + } + + public void Complete(TaskCompletionSource handoff) + { + handoff.TrySetResult(); + lock (_lock) + _pending.Remove(handoff); + } + + public async Task WaitForAllAsync(TimeSpan timeout) + { + Task[] pending; + lock (_lock) + pending = [.. _pending.Select(handoff => handoff.Task)]; + + if (pending.Length == 0) + return true; + + try + { + await Task.WhenAll(pending).WaitAsync(timeout); + return true; + } + catch (TimeoutException) + { + return false; + } + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/MaintenanceScheduler.cs b/src/UniGetUI.Avalonia/Infrastructure/MaintenanceScheduler.cs index b6a36954ae..2ad906b619 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/MaintenanceScheduler.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/MaintenanceScheduler.cs @@ -82,6 +82,15 @@ public static bool IsAutoInstallDue() public static void MarkAutoInstallHandled() => _pendingInstallSince = null; + public static bool IsAnyTaskRunning + { + get + { + lock (RunningTasks) + return RunningTasks.Count > 0; + } + } + public static bool ShouldRunAtAppStart(MaintenanceTaskKind kind) { var schedule = MaintenanceScheduleStore.Get(kind); diff --git a/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs b/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs new file mode 100644 index 0000000000..bd55a50a6c --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs @@ -0,0 +1,83 @@ +using Avalonia.Threading; +using UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; +using UniGetUI.Avalonia.Views; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageOperations; + +namespace UniGetUI.Avalonia.Infrastructure; + +internal static class QuitAfterAutomaticUpdates +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + + private static int _waiting; + + public static void QuitWhenOperationsFinish(Task? launching = null) + { + if (!Settings.Get(Settings.K.QuitAfterAutomaticUpdates)) + return; + + if (Interlocked.Exchange(ref _waiting, 1) == 1) + return; + + _ = WaitAndQuitAsync(launching); + } + + private static async Task WaitAndQuitAsync(Task? launching) + { + try + { + AutomaticQuitDecision decision = await AutomaticQuitMonitor.RunAsync( + launching, + () => Task.Delay(PollInterval), + decide => Dispatcher.UIThread.InvokeAsync(decide).GetTask(), + Sample, + Quit); + + switch (decision) + { + case AutomaticQuitDecision.WindowVisible: + Logger.Info("Automatic updates have finished, but UniGetUI will not quit because its window is open"); + break; + case AutomaticQuitDecision.UnsavedWork: + Logger.Info("Automatic updates have finished, but UniGetUI will not quit because there is unsaved work"); + break; + } + } + catch (Exception ex) + { + Logger.Error(ex); + } + finally + { + Interlocked.Exchange(ref _waiting, 0); + } + } + + private static AutomaticQuitSnapshot? Sample() + { + if (MainWindow.Instance is not { } window) + return null; + + return new AutomaticQuitSnapshot( + IsBusy: IsBusy(), + IsEnabled: Settings.Get(Settings.K.QuitAfterAutomaticUpdates), + IsAlreadyQuitting: window.IsQuitting, + IsWindowVisible: window.IsVisible, + HasUnsavedWork: window.HasUnsavedWork); + } + + private static bool IsBusy() + => AvaloniaOperationRegistry.HasActiveOperations() + || AbstractOperation.QueueLength() > 0 + || MaintenanceScheduler.IsAnyTaskRunning + || BackupViewModel.IsBackupRunning + || AvaloniaAutoUpdater.IsBusy; + + private static void Quit() + { + Logger.ImportantInfo("Automatic updates have finished, quitting UniGetUI as configured"); + MainWindow.Instance?.QuitApplication(); + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs index 8c8a0b7aef..22922cc075 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -79,9 +79,10 @@ private sealed record Entry( new("Check for package updates periodically", ["check for updates", "periodically"], typeof(Scheduler), null), new("Check for updates every:", ["update frequency", "update interval"], typeof(Scheduler), null), new("Install available updates automatically", ["automatic updates", "auto install updates"], typeof(Scheduler), null), - new("Do not automatically install updates when the network connection is metered", ["metered connection"], typeof(Updates), "AUPMeteredCard"), - new("Do not automatically install updates when the device runs on battery", ["battery"], typeof(Updates), "AUPBatteryCard"), - new("Do not automatically install updates when the battery saver is on", ["battery saver"], typeof(Updates), "AUPBatterySaverCard"), + new("Do not automatically install updates when the network connection is metered", ["metered connection"], typeof(Scheduler), "AUPMeteredCard"), + new("Do not automatically install updates when the device runs on battery", ["battery"], typeof(Scheduler), "AUPBatteryCard"), + new("Do not automatically install updates when the battery saver is on", ["battery saver"], typeof(Scheduler), "AUPBatterySaverCard"), + new("Quit UniGetUI once automatic updates have finished, if its window is not open", ["quit", "close", "exit after updates"], typeof(Scheduler), "QuitAfterAutomaticUpdatesCard"), new("Minimum age for updates", ["minimum age", "update security"], typeof(Updates), "MinimumUpdateAgeSelector"), new("Custom minimum age (days)", ["custom age"], typeof(Updates), "MinimumUpdateAgeCustomInput"), new("Warn me when the installer URL host changes between the installed version and the new version (WinGet only)", ["installer host", "url host"], typeof(Updates), "InstallerHostWarningCard"), diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 6dc39332d5..b0a68058e4 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -923,6 +923,11 @@ internal static async Task NavigateThenLoadBundleAsync( return true; } + public bool HasUnsavedWork + => CurrentPageContent is IAsyncLeaveGuard { HasUnsavedWork: true } + || CatalogEditor is { IsBusy: true } or { IsDirty: true } + || BundlesPage.HasChangesToLose; + public async Task CanShutdownAsync(CancellationToken cancellationToken = default) { await _navigationSemaphore.WaitAsync(cancellationToken); diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs index 9f882c1ec3..e3575743a8 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/BackupViewModel.cs @@ -134,7 +134,28 @@ private async Task PickBackupDirectory(Visual? visual) [RelayCommand] private static Task DoLocalBackup(Visual? _) => DoLocalBackupStatic(); - public static async Task DoLocalBackupStatic() + private static int _activeBackups; + + public static bool IsBackupRunning => Volatile.Read(ref _activeBackups) > 0; + + public static Task DoLocalBackupStatic() => TrackBackupAsync(DoLocalBackupCoreAsync); + + public static Task DoCloudBackupStatic() => TrackBackupAsync(DoCloudBackupCoreAsync); + + private static async Task TrackBackupAsync(Func> backup) + { + Interlocked.Increment(ref _activeBackups); + try + { + return await backup(); + } + finally + { + Interlocked.Decrement(ref _activeBackups); + } + } + + private static async Task DoLocalBackupCoreAsync() { try { @@ -302,7 +323,7 @@ private async Task DoCloudBackup() } } - public static async Task DoCloudBackupStatic() + private static async Task DoCloudBackupCoreAsync() { try { diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/UpdatesViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/UpdatesViewModel.cs index 5ae35a08c2..6e721d3887 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/UpdatesViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/UpdatesViewModel.cs @@ -5,7 +5,6 @@ using CommunityToolkit.Mvvm.Input; using UniGetUI.Avalonia.Views.Pages.SettingsPages; using UniGetUI.Core.Tools; -using UniGetUI.Core.Tools.Scheduling; using UniGetUI.PackageEngine; using UniGetUI.PackageEngine.ManagerClasses.Manager; using CoreSettings = UniGetUI.Core.SettingsEngine.Settings; @@ -18,7 +17,6 @@ public partial class UpdatesViewModel : ViewModelBase { public event EventHandler? NavigationRequested; - [ObservableProperty] private bool _isAutomaticUpdatesEnabled; [ObservableProperty] private bool _isCustomAgeSelected; /// Items for the minimum update age ComboboxCard, in display/value pairs. @@ -40,7 +38,6 @@ public UpdatesViewModel() public void RefreshState() { - IsAutomaticUpdatesEnabled = MaintenanceScheduleStore.IsEnabled(MaintenanceTaskKind.InstallUpdates); IsCustomAgeSelected = CoreSettings.GetValue(CoreSettings.K.MinimumUpdateAge) == "custom"; } diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index a60f3ec433..fcadc24663 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -2274,6 +2274,8 @@ public void ShowFromTray() public bool IsQuitting => _shutdownCoordinator.IsQuitting; + internal bool HasUnsavedWork => ViewModel.HasUnsavedWork; + public void QuitApplication() => _ = RequestQuitApplicationAsync(); internal Task RequestQuitApplicationAsync(Action? onAuthorized = null) => @@ -2296,6 +2298,7 @@ private async Task ShutdownApplicationAsync() private static async Task StopAndExitApplicationAsync() { Logger.Warn("Quitting UniGetUI"); + await AvaloniaAutoUpdater.WaitForInstallerHandoffAsync(); try { await AvaloniaBootstrapper.StopIpcApiAsync().WaitAsync(TimeSpan.FromSeconds(5)); diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml.cs index 4ae46eb989..52c567369a 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/AgentPolicyInspector.axaml.cs @@ -97,6 +97,8 @@ private async void OnOpenPolicyEditorRequested(object? sender, PolicyEditorLaunc _ = _viewModel.RefreshPageCommand.ExecuteAsync(null); } + public bool HasUnsavedWork => _activeEditorSession is not null; + public async Task CanLeaveAsync(PageLeaveReason reason, CancellationToken cancellationToken = default) { if (_activeEditorSession is null) return true; diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Scheduler.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Scheduler.axaml index cd2b7c3b63..c1283aed4e 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Scheduler.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Scheduler.axaml @@ -303,6 +303,38 @@ + + + + + + + + + + + Description="{t:Translate Text='Minimum update age, installer host change warnings, etc.'}"/> (TopLevel.GetTopLevel(this) is Window { DataContext: MainWindowViewModel vm }) ? vm : null; + public bool HasUnsavedWork => _currentContent is IAsyncLeaveGuard { HasUnsavedWork: true }; + public async Task CanLeaveAsync( PageLeaveReason reason, CancellationToken cancellationToken = default) diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml index b7ccadc52b..b028f9e854 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml @@ -18,38 +18,9 @@ CornerRadius="8" Icon="SandClock" Text="{t:Translate Scheduled maintenance}" - UnderText="{t:Translate Text='Choose the days and hours when updates are checked and installed'}" + UnderText="{t:Translate Text='Choose when updates are checked and installed, and when automatic installs are skipped or UniGetUI quits'}" Command="{Binding NavigateToSchedulerCommand}"/> - - - - - - - - CanLeaveAsync( PageLeaveReason reason, CancellationToken cancellationToken = default); diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs index 33bf75edf1..4cc736953a 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/PackageBundlesPage.cs @@ -48,6 +48,8 @@ private set } public event EventHandler? UnsavedChangesStateChanged; + public bool HasChangesToLose => _loader.Any() && HasUnsavedChanges; + public PackageBundlesPage() : base(new PackagesPageData { PageName = "SoftwarePages.PackageBundlesPage", @@ -250,7 +252,7 @@ public async Task AskForNewBundle() } private async Task ConfirmDiscardingCurrentBundle() - => !(_loader.Any() && HasUnsavedChanges) || await AskLoseChanges(); + => !HasChangesToLose || await AskLoseChanges(); private void ClearCurrentBundle() { diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs index 464c26a61d..205e0c516a 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs @@ -421,13 +421,15 @@ private static async Task WhenPackagesLoaded() else if (shouldAutoInstall) { MaintenanceScheduler.MarkAutoInstallHandled(); - await LaunchScheduledUpdate(upgradable); + if (await LaunchScheduledUpdate(upgradable)) + QuitAfterAutomaticUpdates.QuitWhenOperationsFinish(); } else if (CoreData.GetProcessArguments().Contains("--updateapps")) { - _ = AvaloniaPackageOperationHelper.UpdateAllAsync(background: true); + Task updateAll = AvaloniaPackageOperationHelper.UpdateAllAsync(background: true); ShowUpgradingPackagesNotification(upgradable); Logger.Warn("Automatic install of updates has been enabled via Command Line (user settings have been overriden)"); + QuitAfterAutomaticUpdates.QuitWhenOperationsFinish(updateAll); } else { @@ -440,7 +442,7 @@ private static async Task WhenPackagesLoaded() } } - private static async Task LaunchScheduledUpdate(IReadOnlyList upgradable) + private static async Task LaunchScheduledUpdate(IReadOnlyList upgradable) { bool markedOnly = MaintenanceScheduleStore.GetInstallTargets() is ScheduleInstallTargets.MarkedPackagesOnly; @@ -467,6 +469,8 @@ private static async Task LaunchScheduledUpdate(IReadOnlyList upgradab if (skipped.Count > 0) ShowAvailableUpdatesNotification(skipped); + + return targets.Count > 0; } private static void ShowAvailableUpdatesNotification(IReadOnlyList upgradable) diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs index 4ee2bdd5a0..f19e68885a 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_Names.cs @@ -128,6 +128,7 @@ public enum K MaintenanceTaskLastRun, MaintenanceTaskLastFailure, NavigationPageOrder, + QuitAfterAutomaticUpdates, // NOTE: Set this to true to delegate package operations to Devolutions Agent broker // instead of using local UAC elevation. Change default here when ready for production. UseAgentBroker, @@ -270,6 +271,7 @@ public static string ResolveKey(K key) K.MaintenanceTaskLastRun => "MaintenanceTaskLastRun", K.MaintenanceTaskLastFailure => "MaintenanceTaskLastFailure", K.NavigationPageOrder => "NavigationPageOrder", + K.QuitAfterAutomaticUpdates => "QuitAfterAutomaticUpdates", K.UseAgentBroker => "UseAgentBroker", K.Test1 => "TestSetting1", diff --git a/src/UniGetUI.Tests/AutomaticQuitMonitorTests.cs b/src/UniGetUI.Tests/AutomaticQuitMonitorTests.cs new file mode 100644 index 0000000000..149d308da5 --- /dev/null +++ b/src/UniGetUI.Tests/AutomaticQuitMonitorTests.cs @@ -0,0 +1,134 @@ +using UniGetUI.Avalonia.Infrastructure; + +namespace UniGetUI.Tests; + +public class AutomaticQuitMonitorTests +{ + private static readonly AutomaticQuitSnapshot Idle = new( + IsBusy: false, + IsEnabled: true, + IsAlreadyQuitting: false, + IsWindowVisible: false, + HasUnsavedWork: false); + + private static readonly AutomaticQuitSnapshot Busy = Idle with { IsBusy = true }; + + private static Task Run( + Queue samples, + Action quit, + Task? launching = null, + Action? onSample = null) + => AutomaticQuitMonitor.RunAsync( + launching, + () => Task.CompletedTask, + decide => Task.FromResult(decide()), + () => + { + onSample?.Invoke(); + return samples.Dequeue(); + }, + quit); + + [Fact] + public async Task QuitsAfterTwoConsecutiveIdleSamples() + { + var samples = new Queue([Idle, Idle]); + int quits = 0; + + AutomaticQuitDecision decision = await Run(samples, () => quits++); + + Assert.Equal(AutomaticQuitDecision.Quit, decision); + Assert.Equal(1, quits); + Assert.Empty(samples); + } + + [Fact] + public async Task BusySampleResetsTheIdleCount() + { + var samples = new Queue([Idle, Busy, Idle, Busy, Idle, Idle]); + int quits = 0; + + AutomaticQuitDecision decision = await Run(samples, () => quits++); + + Assert.Equal(AutomaticQuitDecision.Quit, decision); + Assert.Equal(1, quits); + Assert.Empty(samples); + } + + [Fact] + public async Task DoesNotSampleUntilTheLaunchingWorkCompletes() + { + var launching = new TaskCompletionSource(); + var samples = new Queue([Idle, Idle]); + int sampled = 0; + + Task run = Run(samples, () => { }, launching.Task, () => sampled++); + + Assert.False(run.IsCompleted); + Assert.Equal(0, sampled); + + launching.SetResult(); + + Assert.Equal(AutomaticQuitDecision.Quit, await run); + Assert.Equal(2, sampled); + } + + [Theory] + [InlineData(false, false, false, AutomaticQuitDecision.Cancelled)] + [InlineData(true, true, false, AutomaticQuitDecision.WindowVisible)] + [InlineData(true, false, true, AutomaticQuitDecision.UnsavedWork)] + public async Task RefusesToQuitWhenASafeguardApplies( + bool enabled, + bool windowVisible, + bool unsavedWork, + AutomaticQuitDecision expected) + { + AutomaticQuitSnapshot final = Idle with + { + IsEnabled = enabled, + IsWindowVisible = windowVisible, + HasUnsavedWork = unsavedWork, + }; + var samples = new Queue([Idle, final]); + int quits = 0; + + AutomaticQuitDecision decision = await Run(samples, () => quits++); + + Assert.Equal(expected, decision); + Assert.Equal(0, quits); + } + + [Fact] + public async Task DoesNotQuitTwiceWhenAQuitIsAlreadyInProgress() + { + var samples = new Queue([Idle, Idle with { IsAlreadyQuitting = true }]); + int quits = 0; + + AutomaticQuitDecision decision = await Run(samples, () => quits++); + + Assert.Equal(AutomaticQuitDecision.Cancelled, decision); + Assert.Equal(0, quits); + } + + [Fact] + public async Task StopsWithoutQuittingWhenTheWindowIsGone() + { + var samples = new Queue([Idle, null]); + int quits = 0; + + AutomaticQuitDecision decision = await Run(samples, () => quits++); + + Assert.Equal(AutomaticQuitDecision.Cancelled, decision); + Assert.Equal(0, quits); + } + + [Fact] + public void SafeguardsAreNotCheckedUntilTheMachineIsIdleLongEnough() + { + int idleChecks = 0; + AutomaticQuitSnapshot visible = Idle with { IsWindowVisible = true }; + + Assert.Equal(AutomaticQuitDecision.KeepWaiting, AutomaticQuitMonitor.Evaluate(visible, ref idleChecks)); + Assert.Equal(AutomaticQuitDecision.WindowVisible, AutomaticQuitMonitor.Evaluate(visible, ref idleChecks)); + } +} diff --git a/src/UniGetUI.Tests/InstallerHandoffTrackerTests.cs b/src/UniGetUI.Tests/InstallerHandoffTrackerTests.cs new file mode 100644 index 0000000000..6619a21b5f --- /dev/null +++ b/src/UniGetUI.Tests/InstallerHandoffTrackerTests.cs @@ -0,0 +1,49 @@ +using UniGetUI.Avalonia.Infrastructure; + +namespace UniGetUI.Tests; + +public class InstallerHandoffTrackerTests +{ + [Fact] + public async Task WaitForAll_ReturnsImmediatelyWhenNothingIsPending() + { + var tracker = new InstallerHandoffTracker(); + + Assert.False(tracker.HasPending); + Assert.True(await tracker.WaitForAllAsync(TimeSpan.FromMilliseconds(50))); + } + + [Fact] + public async Task EndingOneAttempt_DoesNotReleaseAnotherAttemptStillAwaitingConsent() + { + var tracker = new InstallerHandoffTracker(); + TaskCompletionSource manual = tracker.Begin(); + TaskCompletionSource automatic = tracker.Begin(); + + tracker.Complete(automatic); + + Assert.True(tracker.HasPending); + Assert.False(await tracker.WaitForAllAsync(TimeSpan.FromMilliseconds(100))); + + Task shutdownWait = tracker.WaitForAllAsync(TimeSpan.FromSeconds(5)); + Assert.False(shutdownWait.IsCompleted); + + tracker.Complete(manual); + + Assert.True(await shutdownWait); + Assert.False(tracker.HasPending); + } + + [Fact] + public async Task CompletingTwice_IsHarmless() + { + var tracker = new InstallerHandoffTracker(); + TaskCompletionSource handoff = tracker.Begin(); + + tracker.Complete(handoff); + tracker.Complete(handoff); + + Assert.False(tracker.HasPending); + Assert.True(await tracker.WaitForAllAsync(TimeSpan.FromMilliseconds(50))); + } +}