Skip to content
3 changes: 3 additions & 0 deletions src/Languages/lang_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
76 changes: 65 additions & 11 deletions src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,43 @@ public static async Task UpdateCheckLoopAsync()
}
}

private static int _activeAttempts;
private static int _attemptsAwaitingConsent;
private static TaskCompletionSource? _installerHandoff;

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.");
Comment thread
GabrielDuf marked this conversation as resolved.
Outdated
}
}

// ------------------------------------------------------------------ core logic
internal static async Task<bool> 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<bool> CheckAndInstallUpdatesCoreAsync(bool autoLaunch, bool manualCheck)
{
ResetUpdateLog(manualCheck, autoLaunch);
UpdaterOverrides overrides = LoadUpdaterOverrides();
Expand Down Expand Up @@ -638,21 +673,39 @@ private static async Task<bool> 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)
var handoff = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_installerHandoff = handoff;
Comment thread
GabrielDuf marked this conversation as resolved.
Outdated
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);
return true;
}
finally
{
handoff.TrySetResult();
Interlocked.CompareExchange(ref _installerHandoff, null, handoff);
}
}

private static async Task LaunchInstallerAsync(string installerLocation)
Expand Down Expand Up @@ -732,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..."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -337,6 +334,9 @@ private static async Task RunPostOperationChecksAsync()
}
}

public static bool HasActiveOperations()
=> Operations.Any(o => o.Status is OperationStatus.Running or OperationStatus.InQueue);
Comment thread
GabrielDuf marked this conversation as resolved.

public static void PromptPendingShortcutsIfAny()
{
if (!OperatingSystem.IsWindows()) return;
Expand Down
9 changes: 9 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/MaintenanceScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
96 changes: 96 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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 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 (true)
{
await Task.Delay(PollInterval);
idleChecks = await Dispatcher.UIThread.InvokeAsync(() => EvaluateIdle(idleChecks));
if (idleChecks < 0)
return;
}
}
catch (Exception ex)
{
Logger.Error(ex);
}
finally
{
Interlocked.Exchange(ref _waiting, 0);
}
}

private static int EvaluateIdle(int idleChecks)
{
if (IsBusy())
return 0;

if (++idleChecks < RequiredIdleChecks)
return idleChecks;
Comment thread
GabrielDuf marked this conversation as resolved.
Outdated

QuitIfStillInBackground();
return -1;
}

private static bool IsBusy()
=> AvaloniaOperationRegistry.HasActiveOperations()
|| AbstractOperation.QueueLength() > 0
Comment thread
GabrielDuf marked this conversation as resolved.
|| MaintenanceScheduler.IsAnyTaskRunning
Comment thread
GabrielDuf marked this conversation as resolved.
|| BackupViewModel.IsBackupRunning
|| AvaloniaAutoUpdater.IsBusy;

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;
}

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();
Comment thread
GabrielDuf marked this conversation as resolved.
Outdated
}
}
7 changes: 4 additions & 3 deletions src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
4 changes: 4 additions & 0 deletions src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,16 @@
private void ToggleOperationsPanel() => OperationsPanelExpanded = !OperationsPanelExpanded;

[RelayCommand]
private void RetryFailedOperations() => AvaloniaOperationRegistry.RetryFailed();

Check warning on line 168 in src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / test-codebase

Member 'RetryFailedOperations' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

[RelayCommand]
private void ClearSuccessfulOperations() => AvaloniaOperationRegistry.ClearSuccessful();

Check warning on line 171 in src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / test-codebase

Member 'ClearSuccessfulOperations' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

[RelayCommand]
private void ClearFinishedOperations() => AvaloniaOperationRegistry.ClearFinished();

Check warning on line 174 in src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / test-codebase

Member 'ClearFinishedOperations' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

[RelayCommand]
private void CancelAllOperations() => AvaloniaOperationRegistry.CancelAll();

Check warning on line 177 in src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs

View workflow job for this annotation

GitHub Actions / test-codebase

Member 'CancelAllOperations' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

// ─── Sidebar ─────────────────────────────────────────────────────────────
public SidebarViewModel Sidebar { get; } = new();
Expand Down Expand Up @@ -923,6 +923,10 @@
return true;
}

public bool HasUnsavedWork
=> CurrentPageContent is IAsyncLeaveGuard { HasUnsavedWork: true }
|| CatalogEditor is { IsBusy: true } or { IsDirty: true };
Comment thread
GabrielDuf marked this conversation as resolved.
Outdated

public async Task<bool> CanShutdownAsync(CancellationToken cancellationToken = default)
{
await _navigationSemaphore.WaitAsync(cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,28 @@ private async Task PickBackupDirectory(Visual? visual)
[RelayCommand]
private static Task DoLocalBackup(Visual? _) => DoLocalBackupStatic();

public static async Task<bool> DoLocalBackupStatic()
private static int _activeBackups;

public static bool IsBackupRunning => Volatile.Read(ref _activeBackups) > 0;

public static Task<bool> DoLocalBackupStatic() => TrackBackupAsync(DoLocalBackupCoreAsync);

public static Task<bool> DoCloudBackupStatic() => TrackBackupAsync(DoCloudBackupCoreAsync);

private static async Task<bool> TrackBackupAsync(Func<Task<bool>> backup)
{
Interlocked.Increment(ref _activeBackups);
try
{
return await backup();
}
finally
{
Interlocked.Decrement(ref _activeBackups);
}
}

private static async Task<bool> DoLocalBackupCoreAsync()
{
try
{
Expand Down Expand Up @@ -302,7 +323,7 @@ private async Task DoCloudBackup()
}
}

public static async Task<bool> DoCloudBackupStatic()
private static async Task<bool> DoCloudBackupCoreAsync()
{
try
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,7 +17,6 @@ public partial class UpdatesViewModel : ViewModelBase
{
public event EventHandler<Type>? NavigationRequested;

[ObservableProperty] private bool _isAutomaticUpdatesEnabled;
[ObservableProperty] private bool _isCustomAgeSelected;

/// <summary>Items for the minimum update age ComboboxCard, in display/value pairs.</summary>
Expand All @@ -40,7 +38,6 @@ public UpdatesViewModel()

public void RefreshState()
{
IsAutomaticUpdatesEnabled = MaintenanceScheduleStore.IsEnabled(MaintenanceTaskKind.InstallUpdates);
IsCustomAgeSelected = CoreSettings.GetValue(CoreSettings.K.MinimumUpdateAge) == "custom";
}

Expand Down
3 changes: 3 additions & 0 deletions src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2274,6 +2274,8 @@ public void ShowFromTray()

public bool IsQuitting => _shutdownCoordinator.IsQuitting;

internal bool HasUnsavedWork => ViewModel.HasUnsavedWork;

public void QuitApplication() => _ = RequestQuitApplicationAsync();

internal Task<bool> RequestQuitApplicationAsync(Action? onAuthorized = null) =>
Expand All @@ -2296,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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> CanLeaveAsync(PageLeaveReason reason, CancellationToken cancellationToken = default)
{
if (_activeEditorSession is null) return true;
Expand Down
Loading
Loading