From 1c7e308fc7ed8cd9bb26cb408503789caca2e754 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 16 Oct 2026 11:17:39 -0400 Subject: [PATCH 1/8] Add an option to quit UniGetUI once automatic updates have finished --- src/Languages/lang_en.json | 1 + .../QuitAfterAutomaticUpdates.cs | 75 +++++++++++++++++++ .../Infrastructure/SettingsSearchIndex.cs | 1 + .../Views/Pages/SettingsPages/Updates.axaml | 7 ++ .../SoftwarePages/SoftwareUpdatesPage.cs | 11 ++- .../SettingsEngine_Names.cs | 2 + 6 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index aa210483a0..2ed977b986 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.", diff --git a/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs b/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs new file mode 100644 index 0000000000..8f9b7f9aac --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs @@ -0,0 +1,75 @@ +using Avalonia.Threading; +using UniGetUI.Avalonia.Views; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Enums; + +namespace UniGetUI.Avalonia.Infrastructure; + +internal static class QuitAfterAutomaticUpdates +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + private const int RequiredIdleChecks = 2; + + 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 + { + if (launching is not null) + await launching; + + int idleChecks = 0; + while (idleChecks < RequiredIdleChecks) + { + await Task.Delay(PollInterval); + bool busy = await Dispatcher.UIThread.InvokeAsync(HasActiveOperations); + idleChecks = busy ? 0 : idleChecks + 1; + } + + await Dispatcher.UIThread.InvokeAsync(QuitIfStillInBackground); + } + catch (Exception ex) + { + Logger.Error(ex); + } + finally + { + Interlocked.Exchange(ref _waiting, 0); + } + } + + private static bool HasActiveOperations() + => AvaloniaOperationRegistry.Operations.Any( + o => o.Status is OperationStatus.Running or OperationStatus.InQueue); + + private static void QuitIfStillInBackground() + { + if (!Settings.Get(Settings.K.QuitAfterAutomaticUpdates)) + return; + + if (MainWindow.Instance is not { } window || window.IsQuitting) + return; + + if (window.IsVisible) + { + Logger.Info("Automatic updates have finished, but UniGetUI will not quit because its window is open"); + return; + } + + Logger.ImportantInfo("Automatic updates have finished, quitting UniGetUI as configured"); + window.QuitApplication(); + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs index 8c8a0b7aef..25ce86459e 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -82,6 +82,7 @@ private sealed record Entry( 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("Quit UniGetUI once automatic updates have finished, if its window is not open", ["quit", "close", "exit after updates"], typeof(Updates), "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/Views/Pages/SettingsPages/Updates.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml index b7ccadc52b..6af7048949 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml @@ -47,6 +47,13 @@ ForceInversion="True" Text="{t:Translate Do not automatically install updates when the battery saver is on}" BorderThickness="1,0,1,1" + CornerRadius="0" + IsEnabled="{Binding IsAutomaticUpdatesEnabled}"/> + + diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs index 464c26a61d..6e3262ea55 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs @@ -401,7 +401,12 @@ private static async Task WhenPackagesLoaded() .Where(p => p.Tag is not PackageTag.OnQueue and not PackageTag.BeingProcessed) .ToList(); - if (upgradable.Count == 0) return; + if (upgradable.Count == 0) + { + if (shouldAutoInstall) + QuitAfterAutomaticUpdates.QuitWhenOperationsFinish(); + return; + } if (Settings.Get(Settings.K.DisableAUPOnBattery) && PowerConditions.IsOnBattery()) { @@ -422,12 +427,14 @@ private static async Task WhenPackagesLoaded() { MaintenanceScheduler.MarkAutoInstallHandled(); 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 { 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", From 28b7d52d3ccf564dead206a7a1adbd4be8954eaf Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 16 Oct 2026 12:01:11 -0400 Subject: [PATCH 2/8] Make the automatic quit wait for background work and never prompt --- .../Infrastructure/AvaloniaAutoUpdater.cs | 38 ++++++++++++++++--- .../AvaloniaOperationRegistry.cs | 8 ++-- .../Infrastructure/MaintenanceScheduler.cs | 9 +++++ .../QuitAfterAutomaticUpdates.cs | 16 +++++--- .../ViewModels/MainWindowViewModel.cs | 4 ++ .../Views/MainWindow.axaml.cs | 2 + .../AgentPolicyInspector.axaml.cs | 2 + .../SettingsPages/SettingsBasePage.axaml.cs | 2 + .../Interfaces/PageInterfaces.cs | 2 + .../SoftwarePages/SoftwareUpdatesPage.cs | 15 +++----- 10 files changed, 74 insertions(+), 24 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index 917d61fd05..84cfe7aed1 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -379,8 +379,26 @@ public static async Task UpdateCheckLoopAsync() } } + private static int _activeAttempts; + private static volatile bool _awaitingInstallConsent; + + internal static bool IsBusy => Volatile.Read(ref _activeAttempts) > 0 && !_awaitingInstallConsent; + // ------------------------------------------------------------------ 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(); @@ -639,15 +657,23 @@ private static async Task PrepareAndLaunchAsync( } // Wait until user requests install, clicks the toast, or the window is being closed - while (!_installRequested && !ReleaseLockForAutoupdate_Window && !ReleaseLockForAutoupdate_Notification) + _awaitingInstallConsent = true; + try { - if (!manualCheck && Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) + while (!_installRequested && !ReleaseLockForAutoupdate_Window && !ReleaseLockForAutoupdate_Notification) { - LogUpdateWarn("Auto-updater: disabled while waiting for user \u2014 aborting."); - MarkAttemptFinished("aborted - auto-update disabled while waiting"); - return true; + 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); } - await Task.Delay(500); + } + finally + { + _awaitingInstallConsent = false; } LogUpdateInfo("Installing update \u2014 launching installer."); 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/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 index 8f9b7f9aac..492ece4e5a 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs @@ -2,7 +2,6 @@ using UniGetUI.Avalonia.Views; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; -using UniGetUI.PackageEngine.Enums; namespace UniGetUI.Avalonia.Infrastructure; @@ -35,7 +34,7 @@ private static async Task WaitAndQuitAsync(Task? launching) while (idleChecks < RequiredIdleChecks) { await Task.Delay(PollInterval); - bool busy = await Dispatcher.UIThread.InvokeAsync(HasActiveOperations); + bool busy = await Dispatcher.UIThread.InvokeAsync(IsBusy); idleChecks = busy ? 0 : idleChecks + 1; } @@ -51,9 +50,10 @@ private static async Task WaitAndQuitAsync(Task? launching) } } - private static bool HasActiveOperations() - => AvaloniaOperationRegistry.Operations.Any( - o => o.Status is OperationStatus.Running or OperationStatus.InQueue); + private static bool IsBusy() + => AvaloniaOperationRegistry.HasActiveOperations() + || MaintenanceScheduler.IsAnyTaskRunning + || AvaloniaAutoUpdater.IsBusy; private static void QuitIfStillInBackground() { @@ -69,6 +69,12 @@ private static void QuitIfStillInBackground() return; } + if (window.HasUnsavedWork) + { + Logger.Info("Automatic updates have finished, but UniGetUI will not quit because there is unsaved work"); + return; + } + Logger.ImportantInfo("Automatic updates have finished, quitting UniGetUI as configured"); window.QuitApplication(); } diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 6dc39332d5..72325af369 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -923,6 +923,10 @@ internal static async Task NavigateThenLoadBundleAsync( return true; } + public bool HasUnsavedWork + => CurrentPageContent is IAsyncLeaveGuard { HasUnsavedWork: true } + || CatalogEditor is { IsBusy: true } or { IsDirty: true }; + public async Task CanShutdownAsync(CancellationToken cancellationToken = default) { await _navigationSemaphore.WaitAsync(cancellationToken); diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index a60f3ec433..34c3f3bb2e 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) => 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/SettingsBasePage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs index 6622731675..87dcff84eb 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs @@ -284,6 +284,8 @@ private static void DisposePage(UserControl? page) private MainWindowViewModel? GetMainWindowViewModel() => (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/SoftwarePages/Interfaces/PageInterfaces.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/Interfaces/PageInterfaces.cs index e710967464..941df72e18 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/Interfaces/PageInterfaces.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/Interfaces/PageInterfaces.cs @@ -32,6 +32,8 @@ public enum PageLeaveReason public interface IAsyncLeaveGuard { + bool HasUnsavedWork { get; } + Task CanLeaveAsync( PageLeaveReason reason, CancellationToken cancellationToken = default); diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs index 6e3262ea55..205e0c516a 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs @@ -401,12 +401,7 @@ private static async Task WhenPackagesLoaded() .Where(p => p.Tag is not PackageTag.OnQueue and not PackageTag.BeingProcessed) .ToList(); - if (upgradable.Count == 0) - { - if (shouldAutoInstall) - QuitAfterAutomaticUpdates.QuitWhenOperationsFinish(); - return; - } + if (upgradable.Count == 0) return; if (Settings.Get(Settings.K.DisableAUPOnBattery) && PowerConditions.IsOnBattery()) { @@ -426,8 +421,8 @@ private static async Task WhenPackagesLoaded() else if (shouldAutoInstall) { MaintenanceScheduler.MarkAutoInstallHandled(); - await LaunchScheduledUpdate(upgradable); - QuitAfterAutomaticUpdates.QuitWhenOperationsFinish(); + if (await LaunchScheduledUpdate(upgradable)) + QuitAfterAutomaticUpdates.QuitWhenOperationsFinish(); } else if (CoreData.GetProcessArguments().Contains("--updateapps")) { @@ -447,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; @@ -474,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) From cf8399358a31c7eb3d64fed718838a0f8db8a54d Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 16 Oct 2026 12:01:21 -0400 Subject: [PATCH 3/8] Move the automatic update restrictions to the scheduler page --- src/Languages/lang_en.json | 2 + .../Infrastructure/SettingsSearchIndex.cs | 8 ++-- .../Pages/SettingsPages/UpdatesViewModel.cs | 3 -- .../Views/Pages/SettingsPages/Scheduler.axaml | 34 ++++++++++++++++- .../Views/Pages/SettingsPages/Updates.axaml | 38 +------------------ 5 files changed, 40 insertions(+), 45 deletions(-) diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index 2ed977b986..dd589f5e24 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1204,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/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs index 25ce86459e..22922cc075 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -79,10 +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("Quit UniGetUI once automatic updates have finished, if its window is not open", ["quit", "close", "exit after updates"], typeof(Updates), "QuitAfterAutomaticUpdatesCard"), + 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/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/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.'}"/> - - - - - - - - - - Date: Fri, 16 Oct 2026 12:19:04 -0400 Subject: [PATCH 4/8] Close the remaining gaps in the automatic quit idle check --- .../Infrastructure/AvaloniaAutoUpdater.cs | 58 ++++++++++++++----- .../QuitAfterAutomaticUpdates.cs | 25 ++++++-- .../Pages/SettingsPages/BackupViewModel.cs | 25 +++++++- .../Views/MainWindow.axaml.cs | 1 + 4 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index 84cfe7aed1..7f47011007 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -380,9 +380,26 @@ public static async Task UpdateCheckLoopAsync() } private static int _activeAttempts; - private static volatile bool _awaitingInstallConsent; + private static int _attemptsAwaitingConsent; + private static TaskCompletionSource? _installerHandoff; - internal static bool IsBusy => Volatile.Read(ref _activeAttempts) > 0 && !_awaitingInstallConsent; + internal static bool IsBusy + => Volatile.Read(ref _activeAttempts) > Volatile.Read(ref _attemptsAwaitingConsent); + + internal static async Task WaitForInstallerHandoffAsync(TimeSpan timeout) + { + if (_installerHandoff is not { } handoff) + return; + + try + { + await handoff.Task.WaitAsync(timeout); + } + catch (TimeoutException) + { + LogUpdateWarn("Auto-updater: the ready installer did not start before UniGetUI shut down."); + } + } // ------------------------------------------------------------------ core logic internal static async Task CheckAndInstallUpdatesAsync(bool autoLaunch = false, bool manualCheck = false) @@ -656,29 +673,39 @@ 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 - _awaitingInstallConsent = true; + var handoff = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _installerHandoff = handoff; try { - while (!_installRequested && !ReleaseLockForAutoupdate_Window && !ReleaseLockForAutoupdate_Notification) + // Wait until user requests install, clicks the toast, or the window is being closed + Interlocked.Increment(ref _attemptsAwaitingConsent); + try { - if (!manualCheck && Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) + while (!_installRequested && !ReleaseLockForAutoupdate_Window && !ReleaseLockForAutoupdate_Notification) { - LogUpdateWarn("Auto-updater: disabled while waiting for user \u2014 aborting."); - MarkAttemptFinished("aborted - auto-update disabled while waiting"); - return true; + 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); } - await Task.Delay(500); } + finally + { + Interlocked.Decrement(ref _attemptsAwaitingConsent); + } + + LogUpdateInfo("Installing update \u2014 launching installer."); + await LaunchInstallerAsync(installerPath); + return true; } finally { - _awaitingInstallConsent = false; + handoff.TrySetResult(); + Interlocked.CompareExchange(ref _installerHandoff, null, handoff); } - - LogUpdateInfo("Installing update \u2014 launching installer."); - await LaunchInstallerAsync(installerPath); - return true; } private static async Task LaunchInstallerAsync(string installerLocation) @@ -758,6 +785,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."); + _installerHandoff?.TrySetResult(); RaiseStatus( CoreTools.Translate("UniGetUI is being updated..."), diff --git a/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs b/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs index 492ece4e5a..ca64c9d03d 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs @@ -1,7 +1,9 @@ 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; @@ -31,14 +33,13 @@ private static async Task WaitAndQuitAsync(Task? launching) await launching; int idleChecks = 0; - while (idleChecks < RequiredIdleChecks) + while (true) { await Task.Delay(PollInterval); - bool busy = await Dispatcher.UIThread.InvokeAsync(IsBusy); - idleChecks = busy ? 0 : idleChecks + 1; + idleChecks = await Dispatcher.UIThread.InvokeAsync(() => EvaluateIdle(idleChecks)); + if (idleChecks < 0) + return; } - - await Dispatcher.UIThread.InvokeAsync(QuitIfStillInBackground); } catch (Exception ex) { @@ -50,9 +51,23 @@ private static async Task WaitAndQuitAsync(Task? launching) } } + private static int EvaluateIdle(int idleChecks) + { + if (IsBusy()) + return 0; + + if (++idleChecks < RequiredIdleChecks) + return idleChecks; + + QuitIfStillInBackground(); + return -1; + } + private static bool IsBusy() => AvaloniaOperationRegistry.HasActiveOperations() + || AbstractOperation.QueueLength() > 0 || MaintenanceScheduler.IsAnyTaskRunning + || BackupViewModel.IsBackupRunning || AvaloniaAutoUpdater.IsBusy; private static void QuitIfStillInBackground() 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/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index 34c3f3bb2e..a04521e375 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -2298,6 +2298,7 @@ private async Task ShutdownApplicationAsync() private static async Task StopAndExitApplicationAsync() { Logger.Warn("Quitting UniGetUI"); + await AvaloniaAutoUpdater.WaitForInstallerHandoffAsync(TimeSpan.FromSeconds(10)); try { await AvaloniaBootstrapper.StopIpcApiAsync().WaitAsync(TimeSpan.FromSeconds(5)); From a3227dc4d468aed93ae95c3c9b3e2d3d596b1b45 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 16 Oct 2026 12:32:07 -0400 Subject: [PATCH 5/8] Give the self-update handoff enough time for archive extraction --- .../Infrastructure/AvaloniaAutoUpdater.cs | 8 +++++--- src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index 7f47011007..d5dc02851e 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -382,22 +382,24 @@ public static async Task UpdateCheckLoopAsync() private static int _activeAttempts; private static int _attemptsAwaitingConsent; private static TaskCompletionSource? _installerHandoff; + 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(TimeSpan timeout) + internal static async Task WaitForInstallerHandoffAsync() { if (_installerHandoff is not { } handoff) return; + LogUpdateInfo("Auto-updater: waiting for the ready update to be handed off before shutting down."); try { - await handoff.Task.WaitAsync(timeout); + await handoff.Task.WaitAsync(InstallerHandoffTimeout); } catch (TimeoutException) { - LogUpdateWarn("Auto-updater: the ready installer did not start before UniGetUI shut down."); + LogUpdateWarn($"Auto-updater: the ready update was not handed off within {InstallerHandoffTimeout.TotalMinutes:0} minutes, shutting down anyway."); } } diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index a04521e375..fcadc24663 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -2298,7 +2298,7 @@ private async Task ShutdownApplicationAsync() private static async Task StopAndExitApplicationAsync() { Logger.Warn("Quitting UniGetUI"); - await AvaloniaAutoUpdater.WaitForInstallerHandoffAsync(TimeSpan.FromSeconds(10)); + await AvaloniaAutoUpdater.WaitForInstallerHandoffAsync(); try { await AvaloniaBootstrapper.StopIpcApiAsync().WaitAsync(TimeSpan.FromSeconds(5)); From f639555ae6a1b579d9304ac8c4ee8995c388bab6 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 16 Oct 2026 12:59:12 -0400 Subject: [PATCH 6/8] Track every outstanding self-update handoff, not just the latest --- .../Infrastructure/AvaloniaAutoUpdater.cs | 24 +++------ .../Infrastructure/InstallerHandoffTracker.cs | 51 +++++++++++++++++++ .../InstallerHandoffTrackerTests.cs | 49 ++++++++++++++++++ 3 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 src/UniGetUI.Avalonia/Infrastructure/InstallerHandoffTracker.cs create mode 100644 src/UniGetUI.Tests/InstallerHandoffTrackerTests.cs diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index d5dc02851e..36d580217f 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -381,7 +381,7 @@ public static async Task UpdateCheckLoopAsync() private static int _activeAttempts; private static int _attemptsAwaitingConsent; - private static TaskCompletionSource? _installerHandoff; + private static readonly InstallerHandoffTracker InstallerHandoffs = new(); private static readonly TimeSpan InstallerHandoffTimeout = TimeSpan.FromMinutes(2); internal static bool IsBusy @@ -389,18 +389,12 @@ internal static bool IsBusy internal static async Task WaitForInstallerHandoffAsync() { - if (_installerHandoff is not { } handoff) + if (!InstallerHandoffs.HasPending) return; LogUpdateInfo("Auto-updater: waiting for the ready update to be handed off before shutting down."); - try - { - await handoff.Task.WaitAsync(InstallerHandoffTimeout); - } - catch (TimeoutException) - { + 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 @@ -675,8 +669,7 @@ private static async Task PrepareAndLaunchAsync( // On first launch in background we wait for user interaction } - var handoff = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _installerHandoff = handoff; + TaskCompletionSource handoff = InstallerHandoffs.Begin(); try { // Wait until user requests install, clicks the toast, or the window is being closed @@ -700,17 +693,16 @@ private static async Task PrepareAndLaunchAsync( } LogUpdateInfo("Installing update \u2014 launching installer."); - await LaunchInstallerAsync(installerPath); + await LaunchInstallerAsync(installerPath, handoff); return true; } finally { - handoff.TrySetResult(); - Interlocked.CompareExchange(ref _installerHandoff, null, handoff); + InstallerHandoffs.Complete(handoff); } } - private static async Task LaunchInstallerAsync(string installerLocation) + private static async Task LaunchInstallerAsync(string installerLocation, TaskCompletionSource handoff) { if (OperatingSystem.IsMacOS()) { @@ -787,7 +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."); - _installerHandoff?.TrySetResult(); + InstallerHandoffs.Complete(handoff); RaiseStatus( CoreTools.Translate("UniGetUI is being updated..."), 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.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))); + } +} From 73ab2c64178c85bf36cb0b1f170dc2d1445c801c Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 16 Oct 2026 13:15:05 -0400 Subject: [PATCH 7/8] Keep the automatic quit from discarding unsaved bundle edits --- src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs | 3 ++- .../Views/SoftwarePages/PackageBundlesPage.cs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 72325af369..b0a68058e4 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -925,7 +925,8 @@ internal static async Task NavigateThenLoadBundleAsync( public bool HasUnsavedWork => CurrentPageContent is IAsyncLeaveGuard { HasUnsavedWork: true } - || CatalogEditor is { IsBusy: true } or { IsDirty: true }; + || CatalogEditor is { IsBusy: true } or { IsDirty: true } + || BundlesPage.HasChangesToLose; public async Task CanShutdownAsync(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() { From 964280cfb80e3bf1cb77c2c255e89d53d0f34d18 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 16 Oct 2026 13:42:07 -0400 Subject: [PATCH 8/8] Cover the automatic quit decision with deterministic tests --- .../Infrastructure/AutomaticQuitMonitor.cs | 75 ++++++++++ .../QuitAfterAutomaticUpdates.cs | 65 ++++----- .../AutomaticQuitMonitorTests.cs | 134 ++++++++++++++++++ 3 files changed, 235 insertions(+), 39 deletions(-) create mode 100644 src/UniGetUI.Avalonia/Infrastructure/AutomaticQuitMonitor.cs create mode 100644 src/UniGetUI.Tests/AutomaticQuitMonitorTests.cs 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/QuitAfterAutomaticUpdates.cs b/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs index ca64c9d03d..bd55a50a6c 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs @@ -10,7 +10,6 @@ namespace UniGetUI.Avalonia.Infrastructure; internal static class QuitAfterAutomaticUpdates { private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); - private const int RequiredIdleChecks = 2; private static int _waiting; @@ -29,16 +28,21 @@ private static async Task WaitAndQuitAsync(Task? launching) { try { - if (launching is not null) - await launching; - - int idleChecks = 0; - while (true) + AutomaticQuitDecision decision = await AutomaticQuitMonitor.RunAsync( + launching, + () => Task.Delay(PollInterval), + decide => Dispatcher.UIThread.InvokeAsync(decide).GetTask(), + Sample, + Quit); + + switch (decision) { - await Task.Delay(PollInterval); - idleChecks = await Dispatcher.UIThread.InvokeAsync(() => EvaluateIdle(idleChecks)); - if (idleChecks < 0) - return; + 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) @@ -51,16 +55,17 @@ private static async Task WaitAndQuitAsync(Task? launching) } } - private static int EvaluateIdle(int idleChecks) + private static AutomaticQuitSnapshot? Sample() { - if (IsBusy()) - return 0; - - if (++idleChecks < RequiredIdleChecks) - return idleChecks; - - QuitIfStillInBackground(); - return -1; + 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() @@ -70,27 +75,9 @@ private static bool IsBusy() || BackupViewModel.IsBackupRunning || AvaloniaAutoUpdater.IsBusy; - private static void QuitIfStillInBackground() + private static void Quit() { - if (!Settings.Get(Settings.K.QuitAfterAutomaticUpdates)) - return; - - if (MainWindow.Instance is not { } window || window.IsQuitting) - return; - - if (window.IsVisible) - { - Logger.Info("Automatic updates have finished, but UniGetUI will not quit because its window is open"); - return; - } - - if (window.HasUnsavedWork) - { - Logger.Info("Automatic updates have finished, but UniGetUI will not quit because there is unsaved work"); - return; - } - Logger.ImportantInfo("Automatic updates have finished, quitting UniGetUI as configured"); - window.QuitApplication(); + MainWindow.Instance?.QuitApplication(); } } 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)); + } +}