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
75 changes: 75 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/AutomaticQuitMonitor.cs
Original file line number Diff line number Diff line change
@@ -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<AutomaticQuitDecision> RunAsync(
Task? launching,
Func<Task> waitForNextSample,
Func<Func<AutomaticQuitDecision>, Task<AutomaticQuitDecision>> runOnUiThread,
Func<AutomaticQuitSnapshot?> 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;
}
}
}
72 changes: 60 additions & 12 deletions src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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,24 +669,40 @@ 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)
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())
{
Expand Down Expand Up @@ -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..."),
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
51 changes: 51 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/InstallerHandoffTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace UniGetUI.Avalonia.Infrastructure;

public sealed class InstallerHandoffTracker
{
private readonly object _lock = new();
private readonly HashSet<TaskCompletionSource> _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<bool> 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;
}
}
}
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
83 changes: 83 additions & 0 deletions src/UniGetUI.Avalonia/Infrastructure/QuitAfterAutomaticUpdates.cs
Original file line number Diff line number Diff line change
@@ -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
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 Quit()
{
Logger.ImportantInfo("Automatic updates have finished, quitting UniGetUI as configured");
MainWindow.Instance?.QuitApplication();
}
}
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
5 changes: 5 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,11 @@
return true;
}

public bool HasUnsavedWork
=> CurrentPageContent is IAsyncLeaveGuard { HasUnsavedWork: true }
|| CatalogEditor is { IsBusy: true } or { IsDirty: true }
|| BundlesPage.HasChangesToLose;

public async Task<bool> CanShutdownAsync(CancellationToken cancellationToken = default)
{
await _navigationSemaphore.WaitAsync(cancellationToken);
Expand Down
Loading
Loading