Skip to content

Commit b5635e6

Browse files
authored
Add a setting to keep update notifications on screen until dismissed (#5681)
1 parent a26df08 commit b5635e6

10 files changed

Lines changed: 170 additions & 11 deletions

File tree

‎src/Languages/lang_en.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,7 @@
973973
"Show a silent notification when an operation is running": "Show a silent notification when an operation is running",
974974
"Show a notification when an operation fails": "Show a notification when an operation fails",
975975
"Show a notification when an operation finishes successfully": "Show a notification when an operation finishes successfully",
976+
"Keep update notifications on screen until they are dismissed": "Keep update notifications on screen until they are dismissed",
976977
"Concurrency and execution": "Concurrency and execution",
977978
"Choose how many operations should be performed in parallel": "Choose how many operations should be performed in parallel",
978979
"Clear successful operations from the operation list after a 5 second delay": "Clear successful operations from the operation list after a 5 second delay",

‎src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ private static void DismissUpdateBanner()
107107
{
108108
var info = new UpdateStatusInfo("", "", InfoBarSeverity.Informational, IsClosable: true, IsOpen: false);
109109
Dispatcher.UIThread.Post(() => StatusChanged?.Invoke(info));
110+
if (OperatingSystem.IsWindows())
111+
WindowsAppNotificationBridge.RemoveSelfUpdateAvailableNotification();
110112
}
111113

112114
// ------------------------------------------------------------------ per-attempt log
@@ -541,6 +543,8 @@ bool IsChannelObsolete() =>
541543
InfoBarSeverity.Success,
542544
isClosable: true);
543545
}
546+
if (OperatingSystem.IsWindows())
547+
WindowsAppNotificationBridge.RemoveSelfUpdateAvailableNotification();
544548
MarkAttemptFinished("no update available");
545549
return true;
546550
}
@@ -1038,6 +1042,7 @@ private static async Task LaunchInstallerAsync(
10381042
}
10391043

10401044
LogUpdateInfo($"Installer process started (PID {p.Id}). The installer is expected to terminate UniGetUI before file replacement.");
1045+
WindowsAppNotificationBridge.RemoveSelfUpdateAvailableNotification();
10411046
InstallerHandoffs.Complete(handoff);
10421047

10431048
RaiseStatus(

‎src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ private sealed record Entry(
7373
new("Show a silent notification when an operation is running", ["progress notification", "silent notification"], typeof(Notifications), "ProgressNotificationCard"),
7474
new("Show a notification when an operation fails", ["error notification", "failure notification"], typeof(Notifications), "ErrorNotificationCard"),
7575
new("Show a notification when an operation finishes successfully", ["success notification"], typeof(Notifications), "SuccessNotificationCard"),
76+
new("Keep update notifications on screen until they are dismissed", ["persistent notification", "reminder", "dismiss"], typeof(Notifications), "PersistentUpdateNotificationsCard", WindowsOnly: true),
7677
new("Notification preferences", ["notifications"], typeof(Notifications), null),
7778

7879
// ── Updates ──────────────────────────────────────────────────────────

‎src/UniGetUI.Avalonia/Infrastructure/Win32ToastNotifier.cs‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System;
33
using System.Runtime.InteropServices;
44
using UniGetUI.Core.Logging;
5+
using UniGetUI.Core.Tools;
56
using Windows.Data.Xml.Dom;
67
using Windows.UI.Notifications;
78

@@ -24,6 +25,8 @@ internal static class Win32ToastNotifier
2425
/// <summary>Stable AUMID for UniGetUI. Must match the one stamped on the Start Menu shortcut.</summary>
2526
public const string AppUserModelId = "Devolutions.UniGetUI";
2627

28+
private const string ToastGroup = "UniGetUI";
29+
2730
private static bool _availabilityChecked;
2831
private static bool _isAvailable;
2932

@@ -80,19 +83,24 @@ public static bool IsAvailable()
8083
/// forwarded to the app when the user clicks the toast. Returns <c>false</c> if the
8184
/// toast could not be shown (caller should fall back to an in-app banner).
8285
/// </summary>
83-
public static bool Show(string title, string message, string launchArgument)
86+
public static bool Show(string title, string message, string launchArgument, string? tag = null, bool untilDismissed = false)
8487
{
8588
if (!OperatingSystem.IsWindows())
8689
return false;
8790

8891
try
8992
{
90-
string xml = BuildToastXml(title, message, launchArgument);
93+
string xml = BuildToastXml(title, message, launchArgument, untilDismissed);
9194

9295
var xmlDoc = new XmlDocument();
9396
xmlDoc.LoadXml(xml);
9497

9598
var notification = new ToastNotification(xmlDoc);
99+
if (tag is not null)
100+
{
101+
notification.Tag = tag;
102+
notification.Group = ToastGroup;
103+
}
96104
ToastNotificationManager.CreateToastNotifier(AppUserModelId).Show(notification);
97105
return true;
98106
}
@@ -104,20 +112,41 @@ public static bool Show(string title, string message, string launchArgument)
104112
}
105113
}
106114

107-
private static string BuildToastXml(string title, string message, string launchArgument)
115+
public static void Remove(string tag)
116+
{
117+
if (!OperatingSystem.IsWindows())
118+
return;
119+
120+
try
121+
{
122+
ToastNotificationManager.History.Remove(tag, ToastGroup, AppUserModelId);
123+
}
124+
catch (Exception ex)
125+
{
126+
Logger.Warn($"Could not remove Windows toast notification '{tag}'");
127+
Logger.Warn(ex);
128+
}
129+
}
130+
131+
internal static string BuildToastXml(string title, string message, string launchArgument, bool untilDismissed = false)
108132
{
109133
string titleEsc = XmlEscape(title);
110134
string messageEsc = XmlEscape(message);
111135
string launchEsc = XmlEscape(launchArgument);
136+
string scenario = untilDismissed ? "reminder" : "default";
137+
string actions = untilDismissed
138+
? $"""<actions><action content="{XmlEscape(CoreTools.Translate("Open UniGetUI"))}" activationType="protocol" arguments="{launchEsc}"/><action content="" activationType="system" arguments="dismiss"/></actions>"""
139+
: "";
112140

113141
return $"""
114-
<toast activationType="protocol" launch="{launchEsc}" scenario="default">
142+
<toast activationType="protocol" launch="{launchEsc}" scenario="{scenario}">
115143
<visual>
116144
<binding template="ToastGeneric">
117145
<text>{titleEsc}</text>
118146
<text>{messageEsc}</text>
119147
</binding>
120148
</visual>
149+
{actions}
121150
<audio silent="true"/>
122151
</toast>
123152
""";

‎src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,26 @@ public static void RemoveProgress(AbstractOperation operation)
101101

102102
// ── updates-available notification ───────────────────────────────────────
103103

104+
internal const string UpdatesToastTag = "updates-available";
105+
internal const string SelfUpdateToastTag = "self-update-available";
106+
107+
internal static bool KeepsUntilDismissed(string? tag, bool persistentUpdateNotifications)
108+
=> persistentUpdateNotifications && tag is (UpdatesToastTag or SelfUpdateToastTag);
109+
110+
public static void RemoveUpdatesAvailableNotification()
111+
{
112+
#if WINDOWS
113+
Win32ToastNotifier.Remove(UpdatesToastTag);
114+
#endif
115+
}
116+
117+
public static void RemoveSelfUpdateAvailableNotification()
118+
{
119+
#if WINDOWS
120+
Win32ToastNotifier.Remove(SelfUpdateToastTag);
121+
#endif
122+
}
123+
104124
/// <summary>
105125
/// Shows a Windows toast notification listing available package updates. No-op on
106126
/// non-Windows or when notifications are disabled (mirrors the previous bridge's
@@ -125,15 +145,17 @@ public static void ShowUpdatesAvailableNotification(IReadOnlyList<IPackage> upgr
125145
CoreTools.Translate("{0} can be updated to version {1}",
126146
upgradable[0].Name, upgradable[0].NewVersionString),
127147
MainWindow.RuntimeNotificationLevel.Success,
128-
launchAction: NotificationArguments.ShowOnUpdatesTab);
148+
launchAction: NotificationArguments.ShowOnUpdatesTab,
149+
tag: UpdatesToastTag);
129150
}
130151
else
131152
{
132153
Show(
133154
CoreTools.Translate("Updates found!"),
134155
CoreTools.Translate("{0} packages can be updated", upgradable.Count),
135156
MainWindow.RuntimeNotificationLevel.Success,
136-
launchAction: NotificationArguments.ShowOnUpdatesTab);
157+
launchAction: NotificationArguments.ShowOnUpdatesTab,
158+
tag: UpdatesToastTag);
137159
}
138160
}
139161
catch (Exception ex)
@@ -200,7 +222,8 @@ public static void ShowSelfUpdateAvailableNotification(string newVersion)
200222
CoreTools.Translate("{0} can be updated to version {1}", "UniGetUI", newVersion),
201223
CoreTools.Translate("You have currently version {0} installed", CoreData.VersionName),
202224
MainWindow.RuntimeNotificationLevel.Success,
203-
launchAction: NotificationArguments.Show);
225+
launchAction: NotificationArguments.Show,
226+
tag: SelfUpdateToastTag);
204227
}
205228
catch (Exception ex)
206229
{
@@ -223,7 +246,8 @@ private static bool Show(
223246
string message,
224247
MainWindow.RuntimeNotificationLevel level,
225248
string launchAction,
226-
bool allowInAppFallback = true)
249+
bool allowInAppFallback = true,
250+
string? tag = null)
227251
{
228252
#if WINDOWS
229253
// Only fire an OS toast when the app is out of sight (minimized or hidden to the tray).
@@ -232,7 +256,8 @@ private static bool Show(
232256
if (OperatingSystem.IsWindows() && !MainWindow.IsWindowOnScreen && Win32ToastNotifier.IsAvailable())
233257
{
234258
string launchArg = BuildLaunchArgument(launchAction);
235-
if (Win32ToastNotifier.Show(title, message, launchArg))
259+
bool untilDismissed = KeepsUntilDismissed(tag, Settings.Get(Settings.K.PersistentUpdateNotifications));
260+
if (Win32ToastNotifier.Show(title, message, launchArg, tag, untilDismissed))
236261
{
237262
// Show-only: the launch action fires only when the user clicks the toast, routed via
238263
// the unigetui:// deep-link through the single-instance handler. Raising it on show

‎src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/NotificationsViewModel.cs‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ namespace UniGetUI.Avalonia.ViewModels.Pages.SettingsPages;
88
public partial class NotificationsViewModel : ViewModelBase
99
{
1010
[ObservableProperty] private bool _isBackgroundModeEnabled;
11-
[ObservableProperty] private bool _isNotificationsEnabled;
11+
[ObservableProperty]
12+
[NotifyPropertyChangedFor(nameof(IsPersistentUpdateNotificationsEnabled))]
13+
private bool _isNotificationsEnabled;
14+
15+
[ObservableProperty]
16+
[NotifyPropertyChangedFor(nameof(IsPersistentUpdateNotificationsEnabled))]
17+
private bool _isUpdatesNotificationsEnabled;
18+
19+
public bool IsPersistentUpdateNotificationsEnabled => IsNotificationsEnabled && IsUpdatesNotificationsEnabled;
1220

1321
/// <summary>True when closing the window quits UniGetUI, which turns notifications off.</summary>
1422
public bool IsSystemTrayWarningVisible => !IsBackgroundModeEnabled;
@@ -18,15 +26,24 @@ public partial class NotificationsViewModel : ViewModelBase
1826
public bool AreNotificationTypesSupported { get; } =
1927
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();
2028

29+
public bool IsPersistentNotificationsSupported { get; } = OperatingSystem.IsWindows();
30+
2131
public NotificationsViewModel()
2232
{
2333
_isBackgroundModeEnabled = !CoreSettings.Get(CoreSettings.K.DisableSystemTray);
2434
_isNotificationsEnabled = !CoreSettings.Get(CoreSettings.K.DisableNotifications);
35+
_isUpdatesNotificationsEnabled = !CoreSettings.Get(CoreSettings.K.DisableUpdatesNotifications);
2536
}
2637

2738
[RelayCommand]
2839
private void UpdateNotificationsEnabled()
2940
{
3041
IsNotificationsEnabled = !CoreSettings.Get(CoreSettings.K.DisableNotifications);
3142
}
43+
44+
[RelayCommand]
45+
private void UpdateUpdatesNotificationsEnabled()
46+
{
47+
IsUpdatesNotificationsEnabled = !CoreSettings.Get(CoreSettings.K.DisableUpdatesNotifications);
48+
}
3249
}

‎src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
<settings:CheckboxCard x:Name="NotificationTypesCard"
5656
SettingName="DisableUpdatesNotifications"
5757
Text="{t:Translate Show a notification when there are available updates}"
58+
StateChangedCommand="{Binding UpdateUpdatesNotificationsEnabledCommand}"
5859
CornerRadius="8,8,0,0"
5960
IsEnabled="{Binding IsNotificationsEnabled}"/>
6061

@@ -77,6 +78,14 @@
7778
BorderThickness="1,0,1,1"
7879
CornerRadius="0,0,8,8"
7980
IsEnabled="{Binding IsNotificationsEnabled}"/>
81+
82+
<settings:CheckboxCard x:Name="PersistentUpdateNotificationsCard"
83+
SettingName="PersistentUpdateNotifications"
84+
Text="{t:Translate Keep update notifications on screen until they are dismissed}"
85+
Margin="0,8,0,0"
86+
CornerRadius="8"
87+
IsVisible="{Binding IsPersistentNotificationsSupported}"
88+
IsEnabled="{Binding IsPersistentUpdateNotificationsEnabled}"/>
8089
</StackPanel>
8190

8291
</StackPanel>

‎src/UniGetUI.Avalonia/Views/SoftwarePages/SoftwareUpdatesPage.cs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ public SoftwareUpdatesPage() : base(new PackagesPageData
5454
})
5555
{
5656
ViewModel.PackagesLoaded += reason => { _ = WhenPackagesLoaded(); };
57+
if (UpgradablePackagesLoader.Instance is { } upgradableLoader)
58+
{
59+
upgradableLoader.PackagesChanged += (_, e) =>
60+
{
61+
if (OperatingSystem.IsWindows() && e.RemovedPackages.Count > 0 && !upgradableLoader.IsLoading && !upgradableLoader.Any())
62+
WindowsAppNotificationBridge.RemoveUpdatesAvailableNotification();
63+
};
64+
}
5765
}
5866

5967
protected override void GenerateToolBar(PackagesPageViewModel vm)
@@ -478,7 +486,13 @@ private static async Task WhenPackagesLoaded()
478486
.Where(p => p.Tag is not PackageTag.OnQueue and not PackageTag.BeingProcessed)
479487
.ToList();
480488

481-
if (upgradable.Count == 0) return;
489+
if (upgradable.Count == 0)
490+
{
491+
if (OperatingSystem.IsWindows()
492+
&& UpgradablePackagesLoader.Instance is { IsLoaded: true, LastLoadReportedFailures: false, AnyManagerReady: true })
493+
WindowsAppNotificationBridge.RemoveUpdatesAvailableNotification();
494+
return;
495+
}
482496

483497
if (Settings.Get(Settings.K.DisableAUPOnBattery) && PowerConditions.IsOnBattery())
484498
{

‎src/UniGetUI.Core.Settings/SettingsEngine_Names.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ public enum K
9898
DisableSuccessNotifications,
9999
DisableProgressNotifications,
100100
ShowOperationSummaryNotifications,
101+
PersistentUpdateNotifications,
101102
KillProcessesThatRefuseToDie,
102103
ManagerPaths,
103104
GitHubUserLogin,
@@ -260,6 +261,7 @@ public static string ResolveKey(K key)
260261
K.DisableSuccessNotifications => "DisableSuccessNotifications",
261262
K.DisableProgressNotifications => "DisableProgressNotifications",
262263
K.ShowOperationSummaryNotifications => "ShowOperationSummaryNotifications",
264+
K.PersistentUpdateNotifications => "PersistentUpdateNotifications",
263265
K.KillProcessesThatRefuseToDie => "KillProcessesThatRefuseToDie",
264266
K.ManagerPaths => "ManagerPaths",
265267
K.GitHubUserLogin => "GitHubUserLogin",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#if WINDOWS
2+
using System.Xml.Linq;
3+
using UniGetUI.Avalonia.Infrastructure;
4+
5+
namespace UniGetUI.Tests;
6+
7+
public sealed class Win32ToastNotifierTests
8+
{
9+
[Fact]
10+
public void DefaultToast_UsesTheDefaultScenarioWithoutActions()
11+
{
12+
XElement toast = XElement.Parse(Win32ToastNotifier.BuildToastXml("Updates found!", "3 packages", "unigetui://showOnUpdatesTab"));
13+
14+
Assert.Equal("default", (string?)toast.Attribute("scenario"));
15+
Assert.Null(toast.Element("actions"));
16+
Assert.Equal("true", (string?)toast.Element("audio")!.Attribute("silent"));
17+
}
18+
19+
[Fact]
20+
public void PersistentToast_UsesTheReminderScenarioWithAnOpenAndADismissButton()
21+
{
22+
XElement toast = XElement.Parse(Win32ToastNotifier.BuildToastXml(
23+
"Updates & \"fixes\"",
24+
"<3 packages>",
25+
"unigetui://showOnUpdatesTab",
26+
untilDismissed: true));
27+
28+
Assert.Equal("reminder", (string?)toast.Attribute("scenario"));
29+
Assert.Equal("unigetui://showOnUpdatesTab", (string?)toast.Attribute("launch"));
30+
Assert.Equal(["Updates & \"fixes\"", "<3 packages>"], toast.Descendants("text").Select(t => t.Value));
31+
32+
XElement[] actions = toast.Element("actions")!.Elements("action").ToArray();
33+
Assert.Equal(2, actions.Length);
34+
35+
Assert.Equal("Open UniGetUI", (string?)actions[0].Attribute("content"));
36+
Assert.Equal("protocol", (string?)actions[0].Attribute("activationType"));
37+
Assert.Equal("unigetui://showOnUpdatesTab", (string?)actions[0].Attribute("arguments"));
38+
39+
Assert.Equal("system", (string?)actions[1].Attribute("activationType"));
40+
Assert.Equal("dismiss", (string?)actions[1].Attribute("arguments"));
41+
Assert.Equal("true", (string?)toast.Element("audio")!.Attribute("silent"));
42+
}
43+
44+
[Theory]
45+
[InlineData(WindowsAppNotificationBridge.UpdatesToastTag, true, true)]
46+
[InlineData(WindowsAppNotificationBridge.SelfUpdateToastTag, true, true)]
47+
[InlineData(WindowsAppNotificationBridge.UpdatesToastTag, false, false)]
48+
[InlineData(WindowsAppNotificationBridge.SelfUpdateToastTag, false, false)]
49+
[InlineData(null, true, false)]
50+
[InlineData("some-other-tag", true, false)]
51+
public void OnlyUpdateNoticesStayUntilDismissed_AndOnlyWhenEnabled(string? tag, bool settingEnabled, bool expected)
52+
{
53+
Assert.Equal(expected, WindowsAppNotificationBridge.KeepsUntilDismissed(tag, settingEnabled));
54+
}
55+
}
56+
#endif

0 commit comments

Comments
 (0)